mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce41ef9c0a | ||
|
|
9aea6ecfc3 | ||
|
|
5040c5c201 | ||
|
|
a3a413f0b1 | ||
|
|
6292e82cec | ||
|
|
2d33773c24 | ||
|
|
e05b420ce9 | ||
|
|
ea8e583ee5 | ||
|
|
3ca9dbfd91 | ||
|
|
15187c4665 | ||
|
|
64bca51619 | ||
|
|
3538ec8005 | ||
|
|
1b234e39e1 | ||
|
|
19bf123c8c | ||
|
|
6cf284a475 | ||
|
|
3b45bb5661 | ||
|
|
6235565480 | ||
|
|
ecb20c4c52 | ||
|
|
a85d16fff8 | ||
|
|
6db93ecfea | ||
|
|
67587dafc2 | ||
|
|
1bbe099835 | ||
|
|
769aa5b468 | ||
|
|
6660944588 | ||
|
|
4b41438af0 | ||
|
|
a4fe432992 | ||
|
|
9279e8be06 | ||
|
|
7f9fcfe3cc | ||
|
|
62c5ab27c5 | ||
|
|
240892495b | ||
|
|
6e7cbf39f0 | ||
|
|
83d257591e | ||
|
|
e42f1a0965 | ||
|
|
fe6dfd3d4d | ||
|
|
d6282885e3 | ||
|
|
bd8b88add2 | ||
|
|
ff9a9e3dd0 | ||
|
|
aa6599a4e0 | ||
|
|
3091a146eb | ||
|
|
000764fdb4 | ||
|
|
56fccf5cfb | ||
|
|
b98673f8c5 | ||
|
|
335cfcc28a | ||
|
|
6aef6890cf | ||
|
|
5b8df9d505 | ||
|
|
6dbc29818c | ||
|
|
6758aceb5d |
+6
-1
@@ -6,7 +6,12 @@
|
|||||||
Server__Name=My EchoHub Server
|
Server__Name=My EchoHub Server
|
||||||
Server__Description=A self-hosted EchoHub chat server
|
Server__Description=A self-hosted EchoHub chat server
|
||||||
Server__PublicServer=false
|
Server__PublicServer=false
|
||||||
# Server__PublicHost=echohub.example.com
|
# Hostnames advertised to the EchoHubSpace directory. Index per entry.
|
||||||
|
# Server__PublicHosts__0=echohub.example.com
|
||||||
|
# Server__PublicHosts__1=alias.example.com
|
||||||
|
# Topic tags surfaced in the EchoHubSpace browser. Index per entry.
|
||||||
|
# Server__Tags__0=community
|
||||||
|
# Server__Tags__1=gaming
|
||||||
# Server__Admins__0=adminUsername
|
# Server__Admins__0=adminUsername
|
||||||
|
|
||||||
# ── JWT ──────────────────────────────────────────────────────────────
|
# ── JWT ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Release Checklist
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-checklist:
|
||||||
|
name: Release Checklist
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Extract version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "::error file=src/Directory.Build.props::Could not read version from Directory.Build.props"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Version: $VERSION"
|
||||||
|
|
||||||
|
- name: Check version was bumped from master
|
||||||
|
run: |
|
||||||
|
BRANCH_VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
git fetch origin master --depth=1
|
||||||
|
MASTER_VERSION=$(git show origin/master:src/Directory.Build.props | grep -oP '(?<=<Version>)[^<]+')
|
||||||
|
echo "Branch: $BRANCH_VERSION | Master: $MASTER_VERSION"
|
||||||
|
if [ "$BRANCH_VERSION" = "$MASTER_VERSION" ]; then
|
||||||
|
echo "::error file=src/Directory.Build.props::Version $BRANCH_VERSION was not bumped from master. Update <Version> in src/Directory.Build.props."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check changelog file exists
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
FILE="docs/changelog/v${VERSION}.md"
|
||||||
|
if [ ! -f "$FILE" ]; then
|
||||||
|
echo "::error::Missing changelog file: $FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Found: $FILE"
|
||||||
|
|
||||||
|
- name: Check changelog TOC
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
if ! grep -q "v${VERSION}.md" docs/changelog/toc.yml; then
|
||||||
|
echo "::error file=docs/changelog/toc.yml::v${VERSION} not found in changelog TOC. Add it to docs/changelog/toc.yml."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q "v${VERSION}" docs/changelog/index.md; then
|
||||||
|
echo "::error file=docs/changelog/index.md::v${VERSION} not found in changelog index. Add it to docs/changelog/index.md."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "toc.yml and index.md: OK"
|
||||||
@@ -95,11 +95,12 @@ graph TD
|
|||||||
### Client
|
### Client
|
||||||
|
|
||||||
- **Runs in your terminal** — no browser, no Electron, no 500MB of bundled Chromium
|
- **Runs in your terminal** — no browser, no Electron, no 500MB of bundled Chromium
|
||||||
- **13 built-in themes** — including `hacker` for when you want to feel like you're in a movie
|
- **14 built-in themes** — including `hacker` for when you want to feel like you're in a movie
|
||||||
- **Slash commands** — `/join`, `/send`, `/status`, `/theme`, etc.
|
- **Slash commands** — `/join`, `/send`, `/status`, `/theme`, etc.
|
||||||
- **Colored nicknames** — pick your hex color, express yourself
|
- **Colored nicknames** — pick your hex color, express yourself
|
||||||
- **Clickable everything** — usernames, @mentions, #channels — just press Enter
|
- **Clickable everything** — usernames, @mentions, #channels — just press Enter
|
||||||
- **File/image sharing** — local files or URLs
|
- **File/image sharing** — local files or URLs; drag & drop a file onto the terminal to send it; save the original behind any ASCII-art image
|
||||||
|
- **End-to-end encrypted rooms** — password-protected channels are encrypted with a passphrase-derived key that never reaches the server, so not even the server owner can read messages or files (they can still see counts and storage size)
|
||||||
- **Multi-server** — save and switch between servers
|
- **Multi-server** — save and switch between servers
|
||||||
- **Auto-reconnect** — drops happen, it rejoins your channels automatically
|
- **Auto-reconnect** — drops happen, it rejoins your channels automatically
|
||||||
- **Auto-updater** — updates in-place with automatic rollback if something goes wrong
|
- **Auto-updater** — updates in-place with automatic rollback if something goes wrong
|
||||||
@@ -224,7 +225,10 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------- | ----------- |
|
| ------- | ----------- |
|
||||||
| `/join <channel>` | Join a channel |
|
| `/join <channel> [password]` | Join a channel (passphrase for encrypted channels) |
|
||||||
|
| `/passwd <old> <new>` | Change the current encrypted channel's passphrase (creator only) |
|
||||||
|
| `/size [s\|m\|l]` | ASCII-art size for attached images (no arg = picker) |
|
||||||
|
| `/downloadpath [path]` | Set the download folder (no path = native folder picker) |
|
||||||
| `/leave` | Leave current channel |
|
| `/leave` | Leave current channel |
|
||||||
| `/topic <text>` | Set channel topic (creator only) |
|
| `/topic <text>` | Set channel topic (creator only) |
|
||||||
| `/send <file or URL>` | Upload a file or image |
|
| `/send <file or URL>` | Upload a file or image |
|
||||||
@@ -239,6 +243,8 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
|||||||
| `/help` | Show help |
|
| `/help` | Show help |
|
||||||
| `/quit` | Exit |
|
| `/quit` | Exit |
|
||||||
|
|
||||||
|
**Message actions:** **right-click a message** for a context menu — delete, save/download/play its attachment, mention the sender, view their profile, or copy the text. (Keyboard alternative: press <kbd>F6</kbd> to focus the message list, select with the arrow keys, and press <kbd>Delete</kbd>; <kbd>F6</kbd> again returns to the input.) You can always delete your own messages; moderators and above can delete others' messages, but only from users below their own role.
|
||||||
|
|
||||||
## Themes
|
## Themes
|
||||||
|
|
||||||
`/theme <name>` to switch:
|
`/theme <name>` to switch:
|
||||||
@@ -247,6 +253,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
|||||||
| ----- | ---- |
|
| ----- | ---- |
|
||||||
| `default` | Gray on black — clean and quiet |
|
| `default` | Gray on black — clean and quiet |
|
||||||
| `transparent` | White on black — for fancy transparent terminals |
|
| `transparent` | White on black — for fancy transparent terminals |
|
||||||
|
| `transparentlight` | Black on transparent — dark characters for light transparent terminals |
|
||||||
| `classic` | White on blue — IRC nostalgia |
|
| `classic` | White on blue — IRC nostalgia |
|
||||||
| `light` | Black on white — for the brave |
|
| `light` | Black on white — for the brave |
|
||||||
| `hacker` | Green on black — *I'm in* |
|
| `hacker` | Green on black — *I'm in* |
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ Terminal.Gui v2 TUI application:
|
|||||||
|
|
||||||
- **UI**: Main window, dialogs, chat renderer with ANSI color support
|
- **UI**: Main window, dialogs, chat renderer with ANSI color support
|
||||||
- **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate)
|
- **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate)
|
||||||
- **Themes**: 13 built-in color themes (including transparent theme with true terminal transparency)
|
- **Themes**: 14 built-in color themes (including transparent dark/light themes with true terminal transparency)
|
||||||
- **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens)
|
- **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens)
|
||||||
|
|
||||||
## Communication
|
## Communication
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/inst
|
|||||||
To install a specific version or to a custom directory:
|
To install a specific version or to a custom directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSfL .../install.sh | sh -s -- --version 0.2.8
|
curl -sSfL .../install.sh | sh -s -- --version 0.2.11
|
||||||
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
|
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix
|
||||||
|
- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata
|
||||||
|
- [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes
|
||||||
|
- [v0.2.9](v0.2.9.md) - Install Script & Chocolatey Fixes
|
||||||
- [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix
|
- [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix
|
||||||
- [v0.2.7](v0.2.7.md) - User List Fix & Terminal.Gui NuGet Migration
|
- [v0.2.7](v0.2.7.md) - User List Fix & Terminal.Gui NuGet Migration
|
||||||
- [v0.2.6](v0.2.6.md) - Major Refactoring & Code Organization
|
- [v0.2.6](v0.2.6.md) - Major Refactoring & Code Organization
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.12
|
||||||
|
href: v0.2.12.md
|
||||||
|
- name: v0.2.11
|
||||||
|
href: v0.2.11.md
|
||||||
|
- name: v0.2.10
|
||||||
|
href: v0.2.10.md
|
||||||
|
- name: v0.2.9
|
||||||
|
href: v0.2.9.md
|
||||||
- name: v0.2.8
|
- name: v0.2.8
|
||||||
href: v0.2.8.md
|
href: v0.2.8.md
|
||||||
- name: v0.2.7
|
- name: v0.2.7
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# v0.2.10
|
||||||
|
|
||||||
|
Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, infinite-scroll message history, and input polish.
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- Command palette — press Ctrl+K from the message input (or anywhere in the main window) to open a searchable dialog for navigating channels and triggering app actions (connect, disconnect, logout, profile, status, create/delete channel, saved servers, toggle users panel, check for updates, quit). Fuzzy matches against both the label and the underlying key so typing `ch` surfaces channel actions alongside `#channel` entries
|
||||||
|
- Scroll-to-load message history — scrolling to the top of a channel now fetches the next batch of older messages in the background (previously only the most recent 100 messages were available). Duplicate messages are filtered by ID, a per-channel guard prevents concurrent fetches, and the scroll position is preserved after the prepend so your reading position doesn't jump
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- Fix update progress dialog freezing / not repainting — progress callbacks now run on the UI thread so the download and extraction percentage actually updates while an update is in progress
|
||||||
|
- Fix pre-update backup failing when a Serilog-held log file is locked — `UpdateBackupService` now enumerates files manually, skips the `logs/` directory and `.log` files, and logs-and-continues on `IOException`/`UnauthorizedAccessException` instead of aborting the whole backup
|
||||||
|
- Simplify update progress dispatch — remove redundant `Application.Invoke` wrappers around progress updates that are already called from the UI thread (introduced while fixing the freeze above)
|
||||||
|
- Fix cursor position being reset to the start of the line when auto-completing commands in the CLI app — insertion point is now moved to the end of the completed text
|
||||||
|
- Fix notification sounds crashing or being silently dropped when several arrive in quick succession — playback is now serialized through a semaphore that's held for the duration of each sound (using `PlaybackFinished` with a 10s safety timeout) and always released in `finally`, so back-to-back notifications queue up and play in order instead of racing the underlying audio player (fixes #20)
|
||||||
|
- Fix client crashing on startup with `No Serilog:Using configuration section is defined` under single-file publish — pass `ConfigurationReaderOptions` with the `Serilog.Sinks.File` assembly explicitly so Serilog can resolve sinks without scanning the filesystem for `.dll`s (which don't exist in a bundled exe)
|
||||||
|
|
||||||
|
## Refactoring
|
||||||
|
|
||||||
|
- Move search-dialog dispatch out of `MainWindow` into `AppOrchestrator` — `MainWindow` now just raises `OnSearchRequested`, keeping the view dumb and letting the orchestrator own navigation/action routing
|
||||||
|
- `ChatHub.GetChannelHistory` and `IChatService.GetChannelHistoryAsync` gain an additional `offset` parameter for paginated history loading (defaults to `0` — existing callers are unaffected)
|
||||||
|
- `ValidationConstants.MaxHistoryCount` raised from `100` to `200` so power users and paginated fetches can request larger batches; `DefaultHistoryCount` stays at `100`
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# v0.2.11
|
||||||
|
|
||||||
|
EchoHubSpace directory protocol overhaul: authenticated server registration with persistent claim tokens, near-real-time user-count updates, and richer server metadata (tags, multi-host, version). Coordinated cutover with the EchoHubSpace directory deploy.
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- EchoHubSpace claim-token authentication — the directory issues a per-server claim token on first registration, persisted atomically alongside the SQLite database (chmod 0600 on Unix). Subsequent reconnects authenticate with the token instead of relying on raw hostname-squatting protection. Token survives both client and directory restarts; lost tokens require an admin-side `DELETE /api/servers/{id}` on the directory to recover
|
||||||
|
- Server tags — public servers can advertise topic tags via the new `Server:Tags` config array, surfacing as filter facets in the EchoHubSpace browser
|
||||||
|
- Multi-host advertisement — a single server can register multiple hostnames (e.g. apex domain, IPv6, alias domains) by listing them in `Server:PublicHosts`. All hosts route to the same directory row
|
||||||
|
- Server version sent to directory — the EchoHubSpace browser shows what version each public server is running, pulled from the server's assembly informational version
|
||||||
|
- Operator-facing `GET /api/server/directory` endpoint (Admin role required) — returns `ServerId`, `IsRegistered`, `LastRegisteredAt`, `LastError`, and any `ConflictingHosts` for support tickets. Never exposes the claim token itself, only a `HasClaimToken` boolean
|
||||||
|
|
||||||
|
## Refactoring
|
||||||
|
|
||||||
|
- Replace 30s polling with event-driven directory updates — `PresenceTracker` now raises `UserCountChanged` only when the distinct user count actually changes (multi-tab/multi-connection users no longer trigger). `ServerDirectoryService` consumes via a single-slot `Channel<int>` (latest-wins coalesces bursts) with a 1-second min-interval throttle. Directory reflects user-count changes within ~1s instead of up to 30s stale
|
||||||
|
- Wrap directory hub responses in a `Response<T>` envelope with `IsSuccess`/`Data`/`Errors`/`Version` shape — protocol version is pinned client-side (currently `1.0`); mismatches trigger a permanent-failure stop with operator-facing log
|
||||||
|
- Stop attempting re-registration after permanent failures (`HostAlreadyClaimed`, `InvalidToken`, `HostConflict`, `InvalidInput`) — the directory no longer terminates the connection on these errors, so the client suppresses re-register on `Reconnected` to avoid tight retry loops. Operator must restart the server after fixing config
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- **Breaking**: `Server:PublicHost` (string) renamed to `Server:PublicHosts` (string array). Public servers must update `appsettings.json` — single-host deployments use a one-element array
|
||||||
|
- New `Server:Tags` (string array) — defaults to empty
|
||||||
|
- New optional `Server:DirectoryClaimPath` — overrides the path of the persisted claim file. Defaults to a `directory-claim.json` next to the SQLite database. Treat the file as a secret; back it up alongside the database
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# v0.2.12
|
||||||
|
|
||||||
|
Private channels are now genuinely private: password-protected channels are end-to-end encrypted, so the server (and its operators) can gate joins and measure storage but cannot read message or file contents. The IRC gateway grows real MODE/TOPIC support and channel keys, and the client gets image "save original", a transparent-light theme, drag-and-drop file sending, Ctrl+V paste, and a fix for the intermittent Ctrl+W crash.
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- **End-to-end encrypted channels** — creating a channel with a password now provisions a zero-knowledge room:
|
||||||
|
- The passphrase never leaves the client. It derives (PBKDF2-SHA256, 210k iterations) two keys: an *auth key* sent to the server as the join credential, and a *key-encryption key* that never leaves the machine.
|
||||||
|
- A random room content key encrypts every message and file with AES-256-GCM. The server only ever stores the room key *wrapped* under the passphrase, so it can gate joins and report a channel's message count, storage size, and attachments — but cannot decrypt any of it. Even the server owner cannot read a private room's contents.
|
||||||
|
- Members' clients cache the derived room key locally (in the per-server config, like saved sessions) so the passphrase isn't retyped every launch; joining on a new device prompts for it once.
|
||||||
|
- Change the passphrase with `/passwd <old> <new>` (channel creator only). The room key is re-wrapped, not rotated, so **existing history stays readable** and members who join later with the new passphrase can still read older messages.
|
||||||
|
- Files and images are encrypted client-side before upload; for images the ASCII-art preview is rendered on the client and stored room-encrypted too. Sending images by URL is disabled in encrypted channels (the server can't fetch-and-render without the key).
|
||||||
|
- End-to-end encrypted channels cannot be joined over the IRC gateway (that would require the server to hold the room key) — IRC `JOIN` returns `475` directing users to the EchoHub client.
|
||||||
|
- Password-protected channels — set an optional password when creating a channel (masked field in the Create Channel dialog, `password` on `POST /api/channels`). Passwords are BCrypt-hashed server-side; the join gate applies on first join only (existing members and the creator are unaffected). Protected channels show a `*` marker in the channel list and `+k` in the status bar
|
||||||
|
- Save original images — image messages now show a clickable "[↓ save original]" line under the ASCII-art preview that downloads the full-resolution original to your Downloads folder (decrypting locally in encrypted channels)
|
||||||
|
- **Messages with attachments (Discord-style)** — a message is now text **plus** a list of attachments instead of being either text or a single file. One message can carry a caption and several files (images, audio, docs) together:
|
||||||
|
- Compose with a **staging tray**: `/send <file>` or dropping files onto the terminal stages them (shown on the input bar); the next Enter sends your typed caption and all staged files as one message. `/clear` drops staged files. `/send <URL>` still posts an image immediately.
|
||||||
|
- Each image attachment renders its own ASCII preview with its own "save original" action; audio/file attachments each get their own play/download line.
|
||||||
|
- In encrypted channels every attachment is encrypted individually (blob + ASCII preview), and the caption is room-encrypted — the server still stores only ciphertext and can report count/size but not contents.
|
||||||
|
- Up to 10 attachments per message.
|
||||||
|
- **Right-click message menu** — right-click any message for a context menu: save/download/play its attachment, mention the sender, view their profile, copy the text, copy the message ID (for linking or command arguments), or delete the message. (Keyboard: F6 focuses the message list for arrow-key selection + Delete.) The selected message is now highlighted while the list is focused.
|
||||||
|
- **Message deletion** — press Delete on a selected message to remove it. You can always delete your own messages; moderators and above can delete others' messages, but only from users **below their own role** (a mod can't delete an admin's or owner's message). Deleting a message also removes its attachment blobs from server storage.
|
||||||
|
- **Customizable download folder** — `/downloadpath` opens your OS-native folder picker (Windows Explorer / macOS Finder / Linux GTK or KDE) to choose where downloaded attachments and saved images go; `/downloadpath <path>` sets it directly (the fallback when no native picker is available). Downloaded files now land in that folder (with automatic `(n)` de-duplication) instead of a temp directory.
|
||||||
|
- `/join <channel> [password]` — join protected channels inline, or let the client prompt: joining a protected channel without a password opens a masked prompt that re-prompts on a wrong password
|
||||||
|
- IRC channel keys — `JOIN #room <key>` works against room passwords (RFC 1459 comma-paired key lists supported); keyless or wrong-key joins get `475 ERR_BADCHANNELKEY`
|
||||||
|
- IRC `MODE` implemented — `MODE #chan` reports `+k`/`+`, `MODE #chan +k <key>` sets and `-k` clears the room password (channel creator or admin only), ban-list probes get a clean empty reply, and `CHANMODES` is advertised in ISUPPORT
|
||||||
|
- IRC `TOPIC` set support — the channel creator can change the topic from IRC; the change broadcasts to connected TUI clients (previously topic changes were rejected with a stub error)
|
||||||
|
- Attach a file by drag & drop or by pasting — drop a file onto the terminal, or **copy a file in your file manager and press Ctrl+V**, to stage it as an attachment (the next Enter sends it with your caption). Multiple files at once are supported. Ctrl+V still pastes text when the clipboard holds text; Ctrl+Y is a paste alias. On Windows the copied-file paste reads the clipboard's file list directly (Windows Terminal never pastes copied files as text), with `xclip`/`wl-paste` used on Linux
|
||||||
|
- Pick ASCII-art size for attached images — `/size` opens a Small/Medium/Large picker (40×40 / 80×80 / 120×120) with descriptions, `/size <s|m|l>` sets it directly, and `/send <file> -l` sets it for that message. The choice is a saved preference and applies to copy-paste/drag-drop images (which have no per-file flag); the current size is shown in the staging tray
|
||||||
|
- New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`)
|
||||||
|
- Timestamps in messages are now aware of the current culture and display the short time pattern for today's messages and the short date+time pattern for older messages.
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- Transparent themes no longer draw an opaque box behind the message input. The input `TextView` renders with the `Editable` visual role, which Terminal.Gui derives as an opaque color when a theme leaves it unset; the themes now pin `Editable`/`ReadOnly` to their base colors so the input matches its (transparent) background.
|
||||||
|
- Attachments whose files have been pruned (retention cleanup deletes blobs older than `Storage:RetentionDays` but left the message rows) no longer render a dead download/preview. When channel history loads, the server checks which attachment blobs still exist: missing ones are dropped from the message, and an attachment-only message whose files are all gone is removed from the database.
|
||||||
|
- Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing
|
||||||
|
- Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement
|
||||||
|
- IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]`
|
||||||
|
|
||||||
|
## API Changes
|
||||||
|
|
||||||
|
- `ChannelDto` gains `isProtected` and `isEncrypted`; `CreateChannelRequest` gains optional `password`, `encryptionSalt`, and `wrappedRoomKey`; SignalR `JoinChannel` takes an optional second `password` argument and `JoinChannelResult` gains `passwordRequired`, `encryptionSalt`, and `wrappedRoomKey` (older clients must update to join over SignalR)
|
||||||
|
- New endpoints: `GET /api/channels/{channel}/crypto` (public crypto metadata — salt only, never the wrapped key) and `POST /api/channels/{channel}/rekey` (creator-only passphrase change)
|
||||||
|
- The upload endpoint accepts `type` and `content` form fields for encrypted channels, where the client supplies the declared message type and room-encrypted content
|
||||||
|
- `ImageToAsciiService` and `FileValidationHelper` moved from `EchoHub.Server` to `EchoHub.Core` so the client can render ASCII art and detect file types for encrypted uploads
|
||||||
|
- **Message shape change**: `MessageDto` drops `Type`/`AttachmentUrl`/`AttachmentFileName`/`AttachmentFileSize` and gains `Attachments` (a list of `AttachmentDto { Kind, Url, FileName, FileSize, AsciiPreview }`, null/empty for plain text). New `Attachment` entity + table with a cascade FK to `Message`
|
||||||
|
- New endpoint `POST /api/channels/{channel}/messages` (multipart: `content` + N `files`, plus `kind`/`preview` per file for encrypted channels) replaces the single-file `upload` endpoint; `DELETE /api/moderation/messages/{id}` now enforces the own-or-higher-role rule
|
||||||
|
- New EF migrations `AddChannelPasswordHash`, `AddChannelEncryptionEnvelope`, and `AddMessageAttachments` (applied automatically on server start); a one-time startup data migration folds legacy single-attachment messages into the new model
|
||||||
@@ -3,3 +3,21 @@
|
|||||||
## Bug Fixes
|
## Bug Fixes
|
||||||
|
|
||||||
- Fix Linux/macOS client install — enable single-file publish so the install script copies one self-contained binary instead of just the native host (which failed with "does not exist: EchoHub.Client.dll")
|
- Fix Linux/macOS client install — enable single-file publish so the install script copies one self-contained binary instead of just the native host (which failed with "does not exist: EchoHub.Client.dll")
|
||||||
|
- Fix Chocolatey install path on Windows — `chocolateyInstall.ps1` was joining the install directory and executable name into a single segment, producing an invalid target path
|
||||||
|
- Fix Chocolatey package metadata — corrected GitHub repository URLs and documentation URLs in `echohub.nuspec` that pointed at the wrong location
|
||||||
|
- Fix double-click on "Public" checkbox in the Create Channel dialog accidentally submitting the dialog — checkbox toggle commands no longer bubble up to the dialog's default button
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Add a dedicated configuration guide (`docs/articles/configuration.md`) covering server settings, client settings, and environment overrides
|
||||||
|
- Refresh README badges and reorganize the articles table of contents for better discoverability
|
||||||
|
- Polish Docker, getting-started, and flow docs to match the current configuration surface
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Bump `Terminal.Gui` to `2.0.0-develop.5043` (from `5039`)
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
- Release workflow now publishes a single-file self-contained client binary for Linux and macOS so the install script works out-of-the-box
|
||||||
|
- Chocolatey publishing step now triggers only when the package source actually changes and performs a proper version check against the feed before pushing
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@
|
|||||||
- [ ] fix the chat trailing; when user scrolls up, and somebody sends a message – the chat instantly "teleports" to the very bottom
|
- [ ] fix the chat trailing; when user scrolls up, and somebody sends a message – the chat instantly "teleports" to the very bottom
|
||||||
- [x] disable the autorun of files (maybe keep for mp4? gotta do some sec research on it)
|
- [x] disable the autorun of files (maybe keep for mp4? gotta do some sec research on it)
|
||||||
- [x] when user creates a new channel, he gets moved to that channel; but the userlist does not refresh the state on that – it refreshes when user re-enters the channel again
|
- [x] when user creates a new channel, he gets moved to that channel; but the userlist does not refresh the state on that – it refreshes when user re-enters the channel again
|
||||||
- [ ] password protected rooms
|
- [x] password protected rooms (end-to-end encrypted — server cannot read contents)
|
||||||
- [ ] better audio lib, current one (NetCoreAudio) does not support seek or other audio actions
|
- [ ] better audio lib, current one (NetCoreAudio) does not support seek or other audio actions
|
||||||
- [ ] Use options pattern for both client & server
|
- [ ] Use options pattern for both client & server
|
||||||
- ref: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-10.0
|
- ref: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-10.0
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
- that means basically multiple servers linked, so users can chat cross-server in this network
|
- that means basically multiple servers linked, so users can chat cross-server in this network
|
||||||
- [x] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch
|
- [x] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch
|
||||||
- [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down)
|
- [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down)
|
||||||
- [ ] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc
|
- [x] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc
|
||||||
- [ ] Actually smart data management – cache messages, lazy load messages on scroll (currently hardcoded 100msgs fetched + new ones)
|
- [ ] Actually smart data management – cache messages, lazy load messages on scroll (currently hardcoded 100msgs fetched + new ones)
|
||||||
- [x] Another thing would be stateful userlist – basically fetch once and listen for userlist updates
|
- [x] Another thing would be stateful userlist – basically fetch once and listen for userlist updates
|
||||||
- [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update
|
- [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
<id>echohub</id>
|
<id>echohub</id>
|
||||||
<version>__VERSION__</version>
|
<version>__VERSION__</version>
|
||||||
<title>EchoHub</title>
|
<title>EchoHub</title>
|
||||||
<authors>HueByte</authors>
|
<authors>HueByte, StoneRed</authors>
|
||||||
<owners>HueByte</owners>
|
<owners>HueByte</owners>
|
||||||
<requireLicenseAcceptance>false</requireLicenseAcceptance>
|
<requireLicenseAcceptance>false</requireLicenseAcceptance>
|
||||||
<licenseUrl>https://github.com/HueByte/EchoHub/blob/master/LICENSE</licenseUrl>
|
<licenseUrl>https://github.com/HueByte/EchoHub/blob/master/LICENSE</licenseUrl>
|
||||||
<projectUrl>https://github.com/HueByte/EchoHub</projectUrl>
|
<projectUrl>https://echohub.voidcube.cloud/</projectUrl>
|
||||||
<projectSourceUrl>https://github.com/HueByte/EchoHub</projectSourceUrl>
|
<projectSourceUrl>https://github.com/HueByte/EchoHub</projectSourceUrl>
|
||||||
<docsUrl>https://huebyte.github.io/EchoHub</docsUrl>
|
<docsUrl>https://huebyte.github.io/EchoHub</docsUrl>
|
||||||
<bugTrackerUrl>https://github.com/HueByte/EchoHub/issues</bugTrackerUrl>
|
<bugTrackerUrl>https://github.com/HueByte/EchoHub/issues</bugTrackerUrl>
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Hue
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
VERIFICATION
|
|
||||||
|
|
||||||
To verify the package contents:
|
|
||||||
|
|
||||||
1. Download the official release from:
|
|
||||||
https://github.com/HueByte/EchoHub/releases
|
|
||||||
|
|
||||||
2. Download: EchoHub-Client-win-x64.zip
|
|
||||||
|
|
||||||
3. Calculate the SHA256 checksum:
|
|
||||||
- PowerShell: Get-FileHash EchoHub-Client-win-x64.zip -Algorithm SHA256
|
|
||||||
- Linux/macOS: sha256sum EchoHub-Client-win-x64.zip
|
|
||||||
|
|
||||||
4. Compare with the checksum in chocolateyInstall.ps1 (checksum64 value).
|
|
||||||
|
|
||||||
LICENSE: MIT License - see LICENSE.txt in this directory or
|
|
||||||
https://github.com/HueByte/EchoHub/blob/master/LICENSE
|
|
||||||
+1
-1
@@ -30,7 +30,7 @@ while [ $# -gt 0 ]; do
|
|||||||
sed -n '2,8p' "$0" 2>/dev/null || true
|
sed -n '2,8p' "$0" 2>/dev/null || true
|
||||||
echo ""
|
echo ""
|
||||||
echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh"
|
echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh"
|
||||||
echo " curl ... | sh -s -- --version 0.2.8"
|
echo " curl ... | sh -s -- --version 0.2.11"
|
||||||
echo " curl ... | sh -s -- --install-dir /opt/echohub"
|
echo " curl ... | sh -s -- --install-dir /opt/echohub"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.9</Version>
|
<Version>0.2.12</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ using EchoHub.Client.UI.Dialogs;
|
|||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Core.Security;
|
||||||
|
using EchoHub.Core.Services;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
@@ -30,6 +32,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private readonly ConnectionManager _conn = new();
|
private readonly ConnectionManager _conn = new();
|
||||||
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Lock _channelUsersLock = new();
|
private readonly Lock _channelUsersLock = new();
|
||||||
|
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly List<string> _stagedAttachments = [];
|
||||||
|
|
||||||
private ClientConfig _config;
|
private ClientConfig _config;
|
||||||
private readonly UserSession _session = new();
|
private readonly UserSession _session = new();
|
||||||
@@ -87,10 +91,14 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
||||||
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
||||||
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||||
|
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
|
||||||
|
_mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested;
|
||||||
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
||||||
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
||||||
_mainWindow.OnUserProfileRequested += HandleViewProfile;
|
_mainWindow.OnUserProfileRequested += HandleViewProfile;
|
||||||
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
||||||
|
_mainWindow.OnSearchRequested += HandleSearchRequested;
|
||||||
|
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||||
@@ -106,6 +114,10 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_commandHandler.OnOpenProfile += HandleCmdOpenProfile;
|
_commandHandler.OnOpenProfile += HandleCmdOpenProfile;
|
||||||
_commandHandler.OnOpenServers += HandleCmdOpenServers;
|
_commandHandler.OnOpenServers += HandleCmdOpenServers;
|
||||||
_commandHandler.OnJoinChannel += HandleCmdJoinChannel;
|
_commandHandler.OnJoinChannel += HandleCmdJoinChannel;
|
||||||
|
_commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword;
|
||||||
|
_commandHandler.OnClearAttachments += HandleCmdClearAttachments;
|
||||||
|
_commandHandler.OnSetAsciiSize += HandleCmdSetAsciiSize;
|
||||||
|
_commandHandler.OnSetDownloadPath += HandleCmdSetDownloadPath;
|
||||||
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
|
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
|
||||||
_commandHandler.OnSetTopic += HandleCmdSetTopic;
|
_commandHandler.OnSetTopic += HandleCmdSetTopic;
|
||||||
_commandHandler.OnListUsers += HandleCmdListUsers;
|
_commandHandler.OnListUsers += HandleCmdListUsers;
|
||||||
@@ -155,32 +167,175 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleCmdSendFile(string target, string? size)
|
private Task HandleCmdSendFile(string target, string? size)
|
||||||
{
|
{
|
||||||
if (!_conn.IsAuthenticated || !_conn.IsConnected) return;
|
if (!_conn.IsAuthenticated || !_conn.IsConnected) return Task.CompletedTask;
|
||||||
|
|
||||||
var channel = _mainWindow.CurrentChannel;
|
var channel = _mainWindow.CurrentChannel;
|
||||||
if (string.IsNullOrEmpty(channel)) return;
|
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||||
|
|
||||||
try
|
// A URL image is sent immediately as its own message (it can't be staged/encrypted).
|
||||||
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
{
|
{
|
||||||
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
if (_conn.RoomKeys.HasKey(channel))
|
||||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
|
||||||
{
|
{
|
||||||
await _conn.Api!.SendUrlAsync(channel, target, size);
|
InvokeUI(() => _mainWindow.ShowError(
|
||||||
|
"Sending by URL isn't available in encrypted channels — download the file and /send it instead."));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
RunAsync(async () => await _conn.Api!.SendUrlAsync(channel, target, size), "Send failed");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local files are staged; the next Enter sends them with the typed caption as one message.
|
||||||
|
if (_stagedAttachments.Count >= HubConstants.MaxAttachmentsPerMessage)
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message."));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit "-s/-m/-l" on /send also sets the message's ASCII size.
|
||||||
|
if (NormalizeAsciiSize(size) is { } flag)
|
||||||
|
_config.DefaultAsciiSize = flag;
|
||||||
|
|
||||||
|
_stagedAttachments.Add(target);
|
||||||
|
InvokeUI(RefreshStagingTray);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleCmdClearAttachments()
|
||||||
|
{
|
||||||
|
_stagedAttachments.Clear();
|
||||||
|
InvokeUI(RefreshStagingTray);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the ASCII-art size picker (no argument) or sets it directly from "s"/"m"/"l" (or
|
||||||
|
/// small/medium/large). The choice is a persistent preference applied to attached images.
|
||||||
|
/// </summary>
|
||||||
|
private Task HandleCmdSetAsciiSize(string args)
|
||||||
|
{
|
||||||
|
var flag = NormalizeAsciiSize(args);
|
||||||
|
if (flag is not null)
|
||||||
|
{
|
||||||
|
InvokeUI(() => ApplyAsciiSize(flag));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
var choice = MessageBox.Query(_app, "ASCII Art Size",
|
||||||
|
"Size of the ASCII rendering for images you attach:\n\n"
|
||||||
|
+ " Small 40 x 40 (compact)\n"
|
||||||
|
+ " Medium 80 x 80 (default)\n"
|
||||||
|
+ " Large 120 x 120 (detailed)",
|
||||||
|
"Small", "Medium", "Large", "Cancel");
|
||||||
|
|
||||||
|
var picked = choice switch { 0 => "s", 1 => "m", 2 => "l", _ => null };
|
||||||
|
if (picked is not null)
|
||||||
|
ApplyAsciiSize(picked);
|
||||||
|
});
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyAsciiSize(string flag)
|
||||||
|
{
|
||||||
|
_config.DefaultAsciiSize = flag;
|
||||||
|
ConfigManager.Save(_config);
|
||||||
|
RefreshStagingTray();
|
||||||
|
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(channel))
|
||||||
|
_messageManager.AddSystemMessage(channel, $"Image ASCII size set to {AsciiSizeLabel(flag)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Refreshes the staging tray with the current staged files and ASCII size.</summary>
|
||||||
|
private void RefreshStagingTray()
|
||||||
|
{
|
||||||
|
var names = _stagedAttachments.Select(Path.GetFileName).OfType<string>().ToList();
|
||||||
|
_mainWindow.SetStagedAttachments(names, AsciiSizeLabel(_config.DefaultAsciiSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeAsciiSize(string? size) => size?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"s" or "small" => "s",
|
||||||
|
"m" or "medium" => "m",
|
||||||
|
"l" or "large" => "l",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string AsciiSizeLabel(string flag) => flag switch
|
||||||
|
{
|
||||||
|
"s" => "Small (40x40)",
|
||||||
|
"l" => "Large (120x120)",
|
||||||
|
_ => "Medium (80x80)",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends one message with the given caption plus all staged files as attachments, then
|
||||||
|
/// clears the staging tray. In encrypted channels each file is room-encrypted (blob +
|
||||||
|
/// ASCII preview) client-side before upload; the caption is room-encrypted too.
|
||||||
|
/// </summary>
|
||||||
|
private void SendStagedMessage(string channel, string content)
|
||||||
|
{
|
||||||
|
var staged = _stagedAttachments.ToList();
|
||||||
|
_stagedAttachments.Clear();
|
||||||
|
InvokeUI(RefreshStagingTray);
|
||||||
|
|
||||||
|
var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey);
|
||||||
|
var size = _config.DefaultAsciiSize;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
var outgoing = new List<OutgoingAttachment>();
|
||||||
|
foreach (var path in staged)
|
||||||
|
outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size));
|
||||||
|
|
||||||
|
var wireContent = hasRoomKey && !string.IsNullOrEmpty(content)
|
||||||
|
? RoomCrypto.EncryptText(content, roomKey)
|
||||||
|
: content;
|
||||||
|
|
||||||
|
await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size);
|
||||||
|
}, "Send failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a staged file into an <see cref="OutgoingAttachment"/>. For encrypted channels the
|
||||||
|
/// blob is AES-GCM encrypted, its kind is declared, and the image ASCII preview is rendered
|
||||||
|
/// locally (at <paramref name="size"/>) and room-encrypted — so the server never sees the
|
||||||
|
/// file or image contents.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<OutgoingAttachment> BuildOutgoingAttachmentAsync(string path, byte[]? roomKey, string size)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(path);
|
||||||
|
|
||||||
|
if (roomKey is null)
|
||||||
|
return new OutgoingAttachment(File.OpenRead(path), fileName);
|
||||||
|
|
||||||
|
var bytes = await File.ReadAllBytesAsync(path);
|
||||||
|
string declaredKind;
|
||||||
|
string? preview = null;
|
||||||
|
|
||||||
|
using (var ms = new MemoryStream(bytes))
|
||||||
|
{
|
||||||
|
if (FileValidationHelper.IsValidImage(ms))
|
||||||
|
{
|
||||||
|
declaredKind = "image";
|
||||||
|
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||||
|
ms.Position = 0;
|
||||||
|
preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await using var stream = File.OpenRead(target);
|
declaredKind = FileValidationHelper.IsAudioFile(fileName) ? "audio" : "file";
|
||||||
var fileName = Path.GetFileName(target);
|
|
||||||
await _conn.Api!.UploadFileAsync(channel, stream, fileName, size);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
var encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey);
|
||||||
Log.Error(ex, "File send failed for {Target}", target);
|
return new OutgoingAttachment(new MemoryStream(encryptedBlob), fileName, declaredKind, preview);
|
||||||
InvokeUI(() => _mainWindow.ShowError($"Send failed: {ex.Message}"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleCmdSetAvatar(string target)
|
private async Task HandleCmdSetAvatar(string target)
|
||||||
@@ -213,13 +368,15 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleCmdJoinChannel(string channelName)
|
private async Task HandleCmdJoinChannel(string channelName, string? password)
|
||||||
{
|
{
|
||||||
if (!_conn.IsConnected) return;
|
if (!_conn.IsConnected) return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var history = await _conn.JoinChannelAsync(channelName);
|
var history = await JoinChannelWithPasswordPromptAsync(channelName, password);
|
||||||
|
if (history is null) return; // user cancelled the password prompt
|
||||||
|
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
_mainWindow.EnsureChannelInList(channelName);
|
_mainWindow.EnsureChannelInList(channelName);
|
||||||
@@ -234,6 +391,145 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Joins a channel, prompting for a password when the server requires one and
|
||||||
|
/// re-prompting on a wrong password. For end-to-end encrypted channels the typed
|
||||||
|
/// passphrase never goes to the server — a PBKDF2-derived auth key is sent instead,
|
||||||
|
/// and the room content key is unwrapped locally. Returns the channel history,
|
||||||
|
/// or null if the user cancelled the prompt.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<MessageDto>?> JoinChannelWithPasswordPromptAsync(string channelName, string? password)
|
||||||
|
{
|
||||||
|
ChannelCryptoDto? crypto = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
crypto = await _conn.Api!.GetChannelCryptoAsync(channelName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Debug(ex, "Crypto metadata unavailable for {Channel}", channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
byte[]? kek = null;
|
||||||
|
var wirePassword = password;
|
||||||
|
if (password is not null && crypto is { IsEncrypted: true, EncryptionSalt: not null })
|
||||||
|
{
|
||||||
|
var derived = RoomCrypto.DeriveKeys(password, Convert.FromBase64String(crypto.EncryptionSalt));
|
||||||
|
wirePassword = derived.AuthKeyHex;
|
||||||
|
kek = derived.KeyEncryptionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var outcome = await _conn.JoinChannelAsync(channelName, wirePassword);
|
||||||
|
|
||||||
|
if (outcome.WrappedRoomKey is not null && !_conn.RoomKeys.HasKey(channelName))
|
||||||
|
{
|
||||||
|
if (kek is not null && RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, kek, out var roomKey))
|
||||||
|
{
|
||||||
|
_conn.RoomKeys.StoreKey(channelName, roomKey);
|
||||||
|
// Re-fetch so history decrypts with the now-available room key
|
||||||
|
return await _conn.GetHistoryAsync(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await UnlockRoomKeyAsync(channelName, outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outcome.History;
|
||||||
|
}
|
||||||
|
catch (ChannelPasswordRequiredException ex)
|
||||||
|
{
|
||||||
|
var prompt = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var message = password is not null ? ex.Message : null;
|
||||||
|
InvokeUI(() => prompt.SetResult(ChannelPasswordDialog.Show(_app, channelName, message)));
|
||||||
|
|
||||||
|
password = await prompt.Task;
|
||||||
|
if (password is null) return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Member of an encrypted channel without a cached room key (e.g. a new device):
|
||||||
|
/// prompt for the passphrase until the room key unwraps or the user gives up.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<MessageDto>?> UnlockRoomKeyAsync(string channelName, JoinOutcome outcome)
|
||||||
|
{
|
||||||
|
if (outcome.EncryptionSalt is null || outcome.WrappedRoomKey is null)
|
||||||
|
return outcome.History;
|
||||||
|
|
||||||
|
var salt = Convert.FromBase64String(outcome.EncryptionSalt);
|
||||||
|
var message = "Enter the passphrase to unlock messages.";
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var prompt = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var promptMessage = message;
|
||||||
|
InvokeUI(() => prompt.SetResult(ChannelPasswordDialog.Show(_app, channelName, promptMessage)));
|
||||||
|
|
||||||
|
var passphrase = await prompt.Task;
|
||||||
|
if (passphrase is null)
|
||||||
|
return outcome.History; // stays locked; placeholders render instead of content
|
||||||
|
|
||||||
|
var derived = RoomCrypto.DeriveKeys(passphrase, salt);
|
||||||
|
if (RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, derived.KeyEncryptionKey, out var roomKey))
|
||||||
|
{
|
||||||
|
_conn.RoomKeys.StoreKey(channelName, roomKey);
|
||||||
|
return await _conn.GetHistoryAsync(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
message = "Wrong passphrase — try again.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes the current encrypted channel's passphrase: re-derives the join credential
|
||||||
|
/// and re-wraps the cached room content key under the new passphrase. History is
|
||||||
|
/// never re-encrypted — the room key itself doesn't change.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleCmdChangeRoomPassword(string oldPassphrase, string newPassphrase)
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated || !_conn.IsConnected) return;
|
||||||
|
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var crypto = await _conn.Api!.GetChannelCryptoAsync(channel);
|
||||||
|
if (crypto is not { IsEncrypted: true } || crypto.EncryptionSalt is null)
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"#{channel} is not an end-to-end encrypted channel."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_conn.RoomKeys.TryGetKey(channel, out var roomKey))
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError("Unlock this channel first (rejoin it with its passphrase), then retry."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldDerived = RoomCrypto.DeriveKeys(oldPassphrase, Convert.FromBase64String(crypto.EncryptionSalt));
|
||||||
|
var newSalt = RoomCrypto.GenerateSalt();
|
||||||
|
var newDerived = RoomCrypto.DeriveKeys(newPassphrase, newSalt);
|
||||||
|
|
||||||
|
await _conn.Api!.RekeyChannelAsync(channel, new RekeyChannelRequest(
|
||||||
|
oldDerived.AuthKeyHex,
|
||||||
|
newDerived.AuthKeyHex,
|
||||||
|
Convert.ToBase64String(newSalt),
|
||||||
|
RoomCrypto.WrapRoomKey(roomKey, newDerived.KeyEncryptionKey)));
|
||||||
|
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(channel,
|
||||||
|
"Passphrase changed. History stays readable; new members and new devices need the new passphrase."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"Passphrase change failed: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleCmdLeaveChannel()
|
private async Task HandleCmdLeaveChannel()
|
||||||
{
|
{
|
||||||
if (!_conn.IsConnected) return;
|
if (!_conn.IsConnected) return;
|
||||||
@@ -551,7 +847,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
if (channel.IsPublic)
|
if (channel.IsPublic)
|
||||||
_mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic);
|
_mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic, channel.IsProtected);
|
||||||
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -692,11 +988,28 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Staged files → one message with the typed caption plus those attachments.
|
||||||
|
if (_stagedAttachments.Count > 0)
|
||||||
|
{
|
||||||
|
SendStagedMessage(channelName, content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
RunAsync(
|
RunAsync(
|
||||||
async () => await _conn.SendMessageAsync(channelName, content),
|
async () => await _conn.SendMessageAsync(channelName, content),
|
||||||
"Send failed");
|
"Send failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleDeleteMessageRequested(Guid messageId)
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated) return;
|
||||||
|
|
||||||
|
// The server enforces the hierarchy rule (own message, or Mod+ over a strictly
|
||||||
|
// lower role) and broadcasts the deletion; the local list updates on that event.
|
||||||
|
RunAsync(async () => await _conn.Api!.DeleteMessageAsync(messageId),
|
||||||
|
"Failed to delete message");
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleChannelSelected(string channelName)
|
private void HandleChannelSelected(string channelName)
|
||||||
{
|
{
|
||||||
if (!_conn.IsConnected) return;
|
if (!_conn.IsConnected) return;
|
||||||
@@ -704,7 +1017,16 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
if (_conn.TrackChannel(channelName))
|
if (_conn.TrackChannel(channelName))
|
||||||
await _conn.JoinChannelAsync(channelName);
|
{
|
||||||
|
var joined = await JoinChannelWithPasswordPromptAsync(channelName, null);
|
||||||
|
if (joined is null)
|
||||||
|
{
|
||||||
|
// User cancelled the password prompt — back to the default channel
|
||||||
|
_conn.UntrackChannel(channelName);
|
||||||
|
InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -720,6 +1042,31 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}, "Failed to join channel");
|
}, "Failed to join channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleLoadMoreRequested()
|
||||||
|
{
|
||||||
|
if (!_conn.IsConnected) return;
|
||||||
|
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return;
|
||||||
|
|
||||||
|
if (!_channelsLoadingMore.Add(channel)) return;
|
||||||
|
|
||||||
|
var offset = _messageManager.GetMessages(channel)?.Count ?? 0;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var history = await _conn.GetHistoryAsync(channel, HubConstants.DefaultHistoryCount, offset);
|
||||||
|
InvokeUI(() => _messageManager.PrependHistory(channel, history));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_channelsLoadingMore.Remove(channel);
|
||||||
|
}
|
||||||
|
}, "Failed to load more messages");
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleChannelJoinFromMessage(string channelName)
|
private void HandleChannelJoinFromMessage(string channelName)
|
||||||
{
|
{
|
||||||
if (!_conn.IsConnected) return;
|
if (!_conn.IsConnected) return;
|
||||||
@@ -733,6 +1080,38 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
HandleChannelSelected(channelName);
|
HandleChannelSelected(channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void HandleSearchRequested()
|
||||||
|
{
|
||||||
|
var result = SearchDialog.Show(_app, _mainWindow.GetChannelNames());
|
||||||
|
if (result is null) return;
|
||||||
|
|
||||||
|
switch (result.Type)
|
||||||
|
{
|
||||||
|
case SearchResultType.Channel:
|
||||||
|
_mainWindow.SwitchToChannel(result.Key);
|
||||||
|
HandleChannelSelected(result.Key);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SearchResultType.Action:
|
||||||
|
switch (result.Key)
|
||||||
|
{
|
||||||
|
case "connect": HandleConnect(); break;
|
||||||
|
case "disconnect": HandleDisconnect(); break;
|
||||||
|
case "logout": HandleLogout(); break;
|
||||||
|
case "profile": HandleProfileRequested(); break;
|
||||||
|
case "status": HandleStatusRequested(); break;
|
||||||
|
case "create-channel": HandleCreateChannelRequested(); break;
|
||||||
|
case "delete-channel": HandleDeleteChannelRequested(); break;
|
||||||
|
case "servers": HandleSavedServersRequested(); break;
|
||||||
|
case "toggle-users": _mainWindow.ToggleUsersPanel(); break;
|
||||||
|
case "updates": HandleCheckForUpdatesRequested(); break;
|
||||||
|
case "quit": _app.RequestStop(); break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleProfileRequested()
|
private void HandleProfileRequested()
|
||||||
{
|
{
|
||||||
HandleViewProfile(null);
|
HandleViewProfile(null);
|
||||||
@@ -923,14 +1302,39 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic);
|
// Password rooms are end-to-end encrypted: derive the join credential and
|
||||||
|
// wrap a fresh room content key locally — the passphrase never leaves here.
|
||||||
|
string? wirePassword = null, saltB64 = null, wrappedKey = null;
|
||||||
|
byte[]? roomKey = null;
|
||||||
|
if (result.Password is not null)
|
||||||
|
{
|
||||||
|
if (result.Password.Length < ValidationConstants.MinChannelPasswordLength)
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError(
|
||||||
|
$"Channel password must be at least {ValidationConstants.MinChannelPasswordLength} characters."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var salt = RoomCrypto.GenerateSalt();
|
||||||
|
var derived = RoomCrypto.DeriveKeys(result.Password, salt);
|
||||||
|
roomKey = RoomCrypto.GenerateRoomKey();
|
||||||
|
wirePassword = derived.AuthKeyHex;
|
||||||
|
saltB64 = Convert.ToBase64String(salt);
|
||||||
|
wrappedKey = RoomCrypto.WrapRoomKey(roomKey, derived.KeyEncryptionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
var channel = await _conn.Api!.CreateChannelAsync(
|
||||||
|
result.Name, result.Topic, result.IsPublic, wirePassword, saltB64, wrappedKey);
|
||||||
if (channel is null) return;
|
if (channel is null) return;
|
||||||
|
|
||||||
var history = await _conn.JoinChannelAsync(channel.Name);
|
if (roomKey is not null)
|
||||||
|
_conn.RoomKeys.StoreKey(channel.Name, roomKey);
|
||||||
|
|
||||||
|
var history = (await _conn.JoinChannelAsync(channel.Name)).History;
|
||||||
|
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
_mainWindow.EnsureChannelInList(channel.Name);
|
_mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic, channel.IsProtected);
|
||||||
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
||||||
_mainWindow.SwitchToChannel(channel.Name);
|
_mainWindow.SwitchToChannel(channel.Name);
|
||||||
if (history.Count > 0)
|
if (history.Count > 0)
|
||||||
@@ -988,11 +1392,85 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||||
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||||
InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName));
|
InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName));
|
||||||
}, "Failed to play audio");
|
}, "Failed to play audio");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads an attachment to a temp file, decrypting it locally when the current
|
||||||
|
/// channel is end-to-end encrypted (the server stores those blobs as ciphertext).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> DownloadAttachmentAsync(string attachmentUrl, string fileName)
|
||||||
|
{
|
||||||
|
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||||
|
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out var roomKey))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var blob = await File.ReadAllBytesAsync(tempPath);
|
||||||
|
await File.WriteAllBytesAsync(tempPath, RoomCrypto.DecryptBytes(blob, roomKey));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Attachment {File} did not decrypt with the room key — keeping raw bytes", fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleImageSaveRequested(string attachmentUrl, string fileName)
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated) return;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||||
|
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||||
|
|
||||||
|
var destination = DedupPath(GetDownloadDir(), fileName);
|
||||||
|
File.Move(tempPath, destination);
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Image saved to: {destination}"));
|
||||||
|
}, "Failed to save image");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the folder downloads are written to: the user's configured
|
||||||
|
/// <see cref="ClientConfig.DownloadPath"/> if set, otherwise the OS Downloads folder.
|
||||||
|
/// Falls back to the temp folder if neither can be created.
|
||||||
|
/// </summary>
|
||||||
|
private string GetDownloadDir()
|
||||||
|
{
|
||||||
|
var dir = _config.DownloadPath;
|
||||||
|
if (string.IsNullOrWhiteSpace(dir))
|
||||||
|
dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Download folder {Dir} is not usable; falling back to temp", dir);
|
||||||
|
return Path.GetTempPath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Appends " (n)" before the extension until the path doesn't collide with an existing file.</summary>
|
||||||
|
private static string DedupPath(string dir, string fileName)
|
||||||
|
{
|
||||||
|
var stem = Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
var ext = Path.GetExtension(fileName);
|
||||||
|
var dest = Path.Combine(dir, fileName);
|
||||||
|
for (var i = 1; File.Exists(dest); i++)
|
||||||
|
dest = Path.Combine(dir, $"{stem} ({i}){ext}");
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// File extensions considered safe to open with the system default application.
|
/// File extensions considered safe to open with the system default application.
|
||||||
/// Everything else is downloaded only — never auto-opened via UseShellExecute.
|
/// Everything else is downloaded only — never auto-opened via UseShellExecute.
|
||||||
@@ -1010,29 +1488,83 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||||
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||||
|
|
||||||
var ext = Path.GetExtension(fileName);
|
var destination = DedupPath(GetDownloadDir(), fileName);
|
||||||
if (SafeOpenExtensions.Contains(ext))
|
File.Move(tempPath, destination);
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Saved to: {destination}"));
|
||||||
|
|
||||||
|
if (SafeOpenExtensions.Contains(Path.GetExtension(fileName)))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
|
var psi = new System.Diagnostics.ProcessStartInfo(destination) { UseShellExecute = true };
|
||||||
System.Diagnostics.Process.Start(psi);
|
System.Diagnostics.Process.Start(psi);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
|
Log.Warning(ex, "Failed to open file with default app: {Path}", destination);
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
|
||||||
}
|
|
||||||
}, "Failed to download file");
|
}, "Failed to download file");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the download folder. With no argument, opens the OS-native folder picker; if that
|
||||||
|
/// isn't available (headless, missing tool), tells the user to pass a path instead. With an
|
||||||
|
/// argument, sets that path directly (the fallback for machines with no native picker).
|
||||||
|
/// </summary>
|
||||||
|
private Task HandleCmdSetDownloadPath(string args)
|
||||||
|
{
|
||||||
|
var current = _config.DownloadPath ?? GetDownloadDir();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(args))
|
||||||
|
{
|
||||||
|
SetDownloadPath(args.Trim());
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
var result = await NativeFolderPicker.PickFolderAsync(current);
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
switch (result.Outcome)
|
||||||
|
{
|
||||||
|
case PickerOutcome.Chosen when result.Path is not null:
|
||||||
|
SetDownloadPath(result.Path);
|
||||||
|
break;
|
||||||
|
case PickerOutcome.Cancelled:
|
||||||
|
_messageManager.AddSystemMessage(_mainWindow.CurrentChannel, "Download folder unchanged.");
|
||||||
|
break;
|
||||||
|
case PickerOutcome.Unavailable:
|
||||||
|
_messageManager.AddSystemMessage(_mainWindow.CurrentChannel,
|
||||||
|
$"No native folder picker here. Current download folder: {current}\nSet one with: /downloadpath <path>");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, "Failed to open folder picker");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetDownloadPath(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"Can't use that folder: {ex.Message}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_config.DownloadPath = path;
|
||||||
|
ConfigManager.Save(_config);
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Download folder set to: {path}"));
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleCheckForUpdatesRequested()
|
private void HandleCheckForUpdatesRequested()
|
||||||
{
|
{
|
||||||
RunAsync(_updateService.CheckNowAsync, "Failed to check for updates");
|
RunAsync(_updateService.CheckNowAsync, "Failed to check for updates");
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ public class CommandHandler
|
|||||||
public event Func<string, string?, Task>? OnSendFile;
|
public event Func<string, string?, Task>? OnSendFile;
|
||||||
public event Func<string?, Task>? OnOpenProfile;
|
public event Func<string?, Task>? OnOpenProfile;
|
||||||
public event Func<Task>? OnOpenServers;
|
public event Func<Task>? OnOpenServers;
|
||||||
public event Func<string, Task>? OnJoinChannel;
|
public event Func<string, string?, Task>? OnJoinChannel;
|
||||||
|
public event Func<string, string, Task>? OnChangeRoomPassword;
|
||||||
|
public event Func<Task>? OnClearAttachments;
|
||||||
|
public event Func<string, Task>? OnSetDownloadPath;
|
||||||
|
public event Func<string, Task>? OnSetAsciiSize;
|
||||||
public event Func<Task>? OnLeaveChannel;
|
public event Func<Task>? OnLeaveChannel;
|
||||||
public event Func<string, Task>? OnSetTopic;
|
public event Func<string, Task>? OnSetTopic;
|
||||||
public event Func<Task>? OnListUsers;
|
public event Func<Task>? OnListUsers;
|
||||||
@@ -47,10 +51,14 @@ public class CommandHandler
|
|||||||
"color" => await HandleColor(args),
|
"color" => await HandleColor(args),
|
||||||
"theme" => await HandleTheme(args),
|
"theme" => await HandleTheme(args),
|
||||||
"send" => await HandleSend(args),
|
"send" => await HandleSend(args),
|
||||||
|
"clear" => await HandleClear(),
|
||||||
|
"size" or "asciisize" => await HandleAsciiSize(args),
|
||||||
|
"downloadpath" or "downloads" => await HandleDownloadPath(args),
|
||||||
"profile" => await HandleProfile(args),
|
"profile" => await HandleProfile(args),
|
||||||
"avatar" => await HandleAvatar(args),
|
"avatar" => await HandleAvatar(args),
|
||||||
"servers" => await HandleServers(),
|
"servers" => await HandleServers(),
|
||||||
"join" => await HandleJoin(args),
|
"join" => await HandleJoin(args),
|
||||||
|
"passwd" => await HandlePasswd(args),
|
||||||
"leave" => await HandleLeave(),
|
"leave" => await HandleLeave(),
|
||||||
"topic" => await HandleTopic(args),
|
"topic" => await HandleTopic(args),
|
||||||
"users" => await HandleUsers(),
|
"users" => await HandleUsers(),
|
||||||
@@ -126,7 +134,7 @@ public class CommandHandler
|
|||||||
private async Task<CommandResult> HandleTheme(string args)
|
private async Task<CommandResult> HandleTheme(string args)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(args))
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
return new CommandResult(true, "Usage: /theme <name> (Default, Dark, Light, Hacker, Solarized)", IsError: true);
|
return new CommandResult(true, "Usage: /theme <name> — pick one from the User menu's theme list (e.g. Default, Transparent, TransparentLight, Hacker)", IsError: true);
|
||||||
|
|
||||||
if (OnSetTheme is not null)
|
if (OnSetTheme is not null)
|
||||||
await OnSetTheme(args.Trim());
|
await OnSetTheme(args.Trim());
|
||||||
@@ -163,6 +171,29 @@ public class CommandHandler
|
|||||||
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleClear()
|
||||||
|
{
|
||||||
|
if (OnClearAttachments is not null)
|
||||||
|
await OnClearAttachments();
|
||||||
|
return new CommandResult(true, "Cleared staged attachments.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleAsciiSize(string args)
|
||||||
|
{
|
||||||
|
// No argument → open the size picker; an argument (s/m/l or small/medium/large) sets it.
|
||||||
|
if (OnSetAsciiSize is not null)
|
||||||
|
await OnSetAsciiSize(args.Trim());
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleDownloadPath(string args)
|
||||||
|
{
|
||||||
|
// No argument → open the native folder picker; an argument sets the path directly.
|
||||||
|
if (OnSetDownloadPath is not null)
|
||||||
|
await OnSetDownloadPath(args.Trim());
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<CommandResult> HandleProfile(string args)
|
private async Task<CommandResult> HandleProfile(string args)
|
||||||
{
|
{
|
||||||
var username = string.IsNullOrWhiteSpace(args) ? null : args.Trim();
|
var username = string.IsNullOrWhiteSpace(args) ? null : args.Trim();
|
||||||
@@ -193,11 +224,28 @@ public class CommandHandler
|
|||||||
private async Task<CommandResult> HandleJoin(string args)
|
private async Task<CommandResult> HandleJoin(string args)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(args))
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
return new CommandResult(true, "Usage: /join <channel>", IsError: true);
|
return new CommandResult(true, "Usage: /join <channel> [password]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Trim().Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var channel = parts[0].TrimStart('#');
|
||||||
|
var password = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : null;
|
||||||
|
|
||||||
var channel = args.Trim().TrimStart('#');
|
|
||||||
if (OnJoinChannel is not null)
|
if (OnJoinChannel is not null)
|
||||||
await OnJoinChannel(channel);
|
await OnJoinChannel(channel, password);
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandlePasswd(string args)
|
||||||
|
{
|
||||||
|
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length != 2)
|
||||||
|
return new CommandResult(true, "Usage: /passwd <old passphrase> <new passphrase> — changes the current encrypted channel's passphrase", IsError: true);
|
||||||
|
|
||||||
|
if (parts[1].Length < 3)
|
||||||
|
return new CommandResult(true, "New passphrase must be at least 3 characters.", IsError: true);
|
||||||
|
|
||||||
|
if (OnChangeRoomPassword is not null)
|
||||||
|
await OnChangeRoomPassword(parts[0], parts[1]);
|
||||||
return new CommandResult(true);
|
return new CommandResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,11 +387,19 @@ public class CommandHandler
|
|||||||
/nick <name> - Set display name
|
/nick <name> - Set display name
|
||||||
/color <#hex> - Set nickname color
|
/color <#hex> - Set nickname color
|
||||||
/theme <name> - Switch theme
|
/theme <name> - Switch theme
|
||||||
/send <filepath or URL> [-s|-m|-l] - Send file/image/audio (size flag for images)
|
/send <filepath> [-s|-m|-l] - Stage a file to attach (Enter sends with your text)
|
||||||
|
/send <URL> [-s|-m|-l] - Send an image URL immediately
|
||||||
|
/clear - Drop all staged attachments
|
||||||
|
/size [s|m|l] - ASCII art size for attached images (no arg = picker)
|
||||||
|
(Tip: copy a file and press Ctrl+V, or drag a file onto the window, to attach it.)
|
||||||
|
(Tip: right-click a message for actions — delete, save/download/play attachment,
|
||||||
|
mention, view profile, copy. Or press F6 to pick a message, then Delete.)
|
||||||
|
/downloadpath [path] - Set download folder (no path = native folder picker)
|
||||||
/avatar <URL or filepath> - Set your avatar
|
/avatar <URL or filepath> - Set your avatar
|
||||||
/profile [username] - View a profile
|
/profile [username] - View a profile
|
||||||
/servers - Open saved servers
|
/servers - Open saved servers
|
||||||
/join <channel> - Join a channel
|
/join <channel> [password] - Join a channel (password if protected)
|
||||||
|
/passwd <old> <new> - Change current encrypted channel's passphrase
|
||||||
/leave - Leave current channel
|
/leave - Leave current channel
|
||||||
/topic <text> - Set channel topic
|
/topic <text> - Set channel topic
|
||||||
/users - List online users
|
/users - List online users
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ public class ClientConfig
|
|||||||
public AccountPreset DefaultPreset { get; set; } = new();
|
public AccountPreset DefaultPreset { get; set; } = new();
|
||||||
public string ActiveTheme { get; set; } = "Default";
|
public string ActiveTheme { get; set; } = "Default";
|
||||||
public NotificationConfig Notifications { get; set; } = new();
|
public NotificationConfig Notifications { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Folder where downloaded attachments and saved images are written. When null, the
|
||||||
|
/// OS Downloads folder is used. Set via the native folder picker or <c>/downloadpath</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? DownloadPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ASCII-art rendering size for images you attach: "s" (40×40), "m" (80×80), or "l" (120×120).
|
||||||
|
/// Applies to copy-paste/drag-drop attachments, which have no per-file size flag.
|
||||||
|
/// </summary>
|
||||||
|
public string DefaultAsciiSize { get; set; } = "m";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class NotificationConfig
|
public class NotificationConfig
|
||||||
@@ -23,6 +35,13 @@ public class SavedServer
|
|||||||
public string? RefreshToken { get; set; }
|
public string? RefreshToken { get; set; }
|
||||||
public bool RememberMe { get; set; }
|
public bool RememberMe { get; set; }
|
||||||
public DateTimeOffset LastConnected { get; set; }
|
public DateTimeOffset LastConnected { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cached room content keys for end-to-end encrypted channels on this server,
|
||||||
|
/// keyed by channel name (base64). Like RefreshToken, these live only on the
|
||||||
|
/// user's machine — the server never sees them.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> ChannelKeys { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AccountPreset
|
public class AccountPreset
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using EchoHub.Client.Services;
|
|||||||
using EchoHub.Client.Themes;
|
using EchoHub.Client.Themes;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using Serilog.Settings.Configuration;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
|
|
||||||
// == CLI rollback: works without TUI, before anything else ================
|
// == CLI rollback: works without TUI, before anything else ================
|
||||||
@@ -71,8 +72,11 @@ var configuration = new ConfigurationBuilder()
|
|||||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
|
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
// Explicit sink-assembly reference is required under PublishSingleFile — the default
|
||||||
|
// AssemblyFinder scans for Serilog.Sinks.*.dll on disk, which don't exist in a bundled exe.
|
||||||
|
var serilogOptions = new ConfigurationReaderOptions(typeof(FileLoggerConfigurationExtensions).Assembly);
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.ReadFrom.Configuration(configuration)
|
.ReadFrom.Configuration(configuration, serilogOptions)
|
||||||
.CreateLogger();
|
.CreateLogger();
|
||||||
|
|
||||||
Log.Information("EchoHub client starting");
|
Log.Information("EchoHub client starting");
|
||||||
|
|||||||
@@ -185,17 +185,35 @@ public sealed class ApiClient : IDisposable
|
|||||||
return result?.AvatarAscii;
|
return result?.AvatarAscii;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null)
|
/// <summary>
|
||||||
|
/// Sends one message with optional text and one or more file attachments.
|
||||||
|
/// For end-to-end encrypted channels each attachment carries a declared kind and a
|
||||||
|
/// room-encrypted preview (empty when none); the caption is likewise room-encrypted.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<MessageDto?> SendMessageWithAttachmentsAsync(
|
||||||
|
string channelName, string content, IReadOnlyList<OutgoingAttachment> attachments, string? size = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
using var content = new MultipartFormDataContent();
|
using var form = new MultipartFormDataContent { { new StringContent(content), "content" } };
|
||||||
using var streamContent = new StreamContent(fileStream);
|
|
||||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
foreach (var att in attachments)
|
||||||
content.Add(streamContent, "file", fileName);
|
{
|
||||||
|
var streamContent = new StreamContent(att.Stream);
|
||||||
|
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(att.FileName));
|
||||||
|
form.Add(streamContent, "file", att.FileName);
|
||||||
|
|
||||||
|
// Encrypted channels: one kind + preview per file, in the same order, to keep
|
||||||
|
// the server's index alignment (empty preview string for non-images).
|
||||||
|
if (att.DeclaredKind is not null)
|
||||||
|
{
|
||||||
|
form.Add(new StringContent(att.DeclaredKind), "kind");
|
||||||
|
form.Add(new StringContent(att.EncryptedPreview ?? string.Empty), "preview");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var sizeQuery = size is not null ? $"?size={size}" : "";
|
var sizeQuery = size is not null ? $"?size={size}" : "";
|
||||||
using var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
|
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/messages{sizeQuery}", form));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
}
|
}
|
||||||
@@ -228,16 +246,40 @@ public sealed class ApiClient : IDisposable
|
|||||||
return tempPath;
|
return tempPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
|
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true,
|
||||||
|
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new CreateChannelRequest(name, topic, isPublic);
|
var request = new CreateChannelRequest(name, topic, isPublic, password, encryptionSalt, wrappedRoomKey);
|
||||||
using var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync("/api/channels", request));
|
_http.PostAsJsonAsync("/api/channels", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches a channel's public crypto metadata (whether it's E2E-encrypted and its
|
||||||
|
/// key-derivation salt). Returns null when the channel doesn't exist.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedGetAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/crypto");
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
return null;
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<ChannelCryptoDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChannelDto?> RekeyChannelAsync(string channelName, RekeyChannelRequest request)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/rekey", request));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ChannelDto?> UpdateChannelTopicAsync(string channelName, string? topic)
|
public async Task<ChannelDto?> UpdateChannelTopicAsync(string channelName, string? topic)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads file paths that live on the OS clipboard as a *file list* (e.g. after copying a file in
|
||||||
|
/// Explorer/Finder/Nautilus), which terminals do not paste as text. Lets Ctrl+V attach a copied
|
||||||
|
/// file directly instead of requiring the user to paste a raw path.
|
||||||
|
/// </summary>
|
||||||
|
public static class ClipboardFiles
|
||||||
|
{
|
||||||
|
public static bool TryGetFiles(out List<string> files)
|
||||||
|
{
|
||||||
|
files = [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
return TryGetWindows(out files);
|
||||||
|
if (OperatingSystem.IsLinux())
|
||||||
|
return TryGetLinux(out files);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Reading files from the clipboard failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS and everything else: no file-list clipboard support (text paste still works).
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Windows: CF_HDROP via the Win32 clipboard ────────────────────────────
|
||||||
|
|
||||||
|
private const uint CfHdrop = 15;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static bool TryGetWindows(out List<string> files)
|
||||||
|
{
|
||||||
|
files = [];
|
||||||
|
if (!IsClipboardFormatAvailable(CfHdrop))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// The clipboard may briefly be held by another process; a few quick retries cover that.
|
||||||
|
var opened = false;
|
||||||
|
for (var attempt = 0; attempt < 5 && !opened; attempt++)
|
||||||
|
opened = OpenClipboard(IntPtr.Zero);
|
||||||
|
if (!opened)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hDrop = GetClipboardData(CfHdrop);
|
||||||
|
if (hDrop == IntPtr.Zero)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var count = DragQueryFileW(hDrop, 0xFFFFFFFF, null, 0);
|
||||||
|
for (uint i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var len = DragQueryFileW(hDrop, i, null, 0);
|
||||||
|
if (len == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var sb = new StringBuilder((int)len + 1);
|
||||||
|
DragQueryFileW(hDrop, i, sb, (uint)sb.Capacity);
|
||||||
|
var path = sb.ToString();
|
||||||
|
if (File.Exists(path))
|
||||||
|
files.Add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.Count > 0;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CloseClipboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool OpenClipboard(IntPtr hWndNewOwner);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool CloseClipboard();
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool IsClipboardFormatAvailable(uint format);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern IntPtr GetClipboardData(uint uFormat);
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern uint DragQueryFileW(IntPtr hDrop, uint iFile, StringBuilder? lpszFile, uint cch);
|
||||||
|
|
||||||
|
// ── Linux: text/uri-list from the clipboard via xclip or wl-paste ─────────
|
||||||
|
|
||||||
|
[SupportedOSPlatform("linux")]
|
||||||
|
private static bool TryGetLinux(out List<string> files)
|
||||||
|
{
|
||||||
|
files = [];
|
||||||
|
|
||||||
|
var output = RunForOutput("wl-paste", ["--type", "text/uri-list", "--no-newline"])
|
||||||
|
?? RunForOutput("xclip", ["-selection", "clipboard", "-t", "text/uri-list", "-o"]);
|
||||||
|
if (string.IsNullOrWhiteSpace(output))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
if (!line.StartsWith("file://", StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = new Uri(line).LocalPath;
|
||||||
|
if (File.Exists(path))
|
||||||
|
files.Add(path);
|
||||||
|
}
|
||||||
|
catch (UriFormatException) { /* skip malformed entry */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? RunForOutput(string fileName, IEnumerable<string> args)
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo(fileName)
|
||||||
|
{
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
foreach (var arg in args)
|
||||||
|
psi.ArgumentList.Add(arg);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.Start(psi);
|
||||||
|
if (process is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
process.WaitForExit(2000);
|
||||||
|
return process.ExitCode == 0 ? output : null;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException)
|
||||||
|
{
|
||||||
|
return null; // tool not installed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
private EchoHubConnection? _connection;
|
private EchoHubConnection? _connection;
|
||||||
private ApiClient? _apiClient;
|
private ApiClient? _apiClient;
|
||||||
private readonly ClientEncryptionService _encryption = new();
|
private readonly ClientEncryptionService _encryption = new();
|
||||||
|
private readonly RoomKeyStore _roomKeys = new();
|
||||||
private readonly HashSet<string> _joinedChannels = [];
|
private readonly HashSet<string> _joinedChannels = [];
|
||||||
|
|
||||||
// ── Properties ────────────────────────────────────────────────────────
|
// ── Properties ────────────────────────────────────────────────────────
|
||||||
@@ -31,6 +32,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
public bool IsConnected => _connection?.IsConnected == true;
|
public bool IsConnected => _connection?.IsConnected == true;
|
||||||
public bool IsAuthenticated => _apiClient is not null;
|
public bool IsAuthenticated => _apiClient is not null;
|
||||||
public ApiClient? Api => _apiClient;
|
public ApiClient? Api => _apiClient;
|
||||||
|
public RoomKeyStore RoomKeys => _roomKeys;
|
||||||
|
|
||||||
// ── Events (forwarded from SignalR) ───────────────────────────────────
|
// ── Events (forwarded from SignalR) ───────────────────────────────────
|
||||||
|
|
||||||
@@ -101,7 +103,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
if (_connection is not null)
|
if (_connection is not null)
|
||||||
await _connection.DisposeAsync();
|
await _connection.DisposeAsync();
|
||||||
|
|
||||||
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
|
_roomKeys.LoadForServer(info.ServerUrl);
|
||||||
|
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption, _roomKeys);
|
||||||
WireConnectionEvents(_connection);
|
WireConnectionEvents(_connection);
|
||||||
await _connection.ConnectAsync();
|
await _connection.ConnectAsync();
|
||||||
|
|
||||||
@@ -156,6 +159,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
_apiClient?.Dispose();
|
_apiClient?.Dispose();
|
||||||
_apiClient = null;
|
_apiClient = null;
|
||||||
_joinedChannels.Clear();
|
_joinedChannels.Clear();
|
||||||
|
_roomKeys.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -169,11 +173,21 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
|
|
||||||
// ── Channel Operations ────────────────────────────────────────────────
|
// ── Channel Operations ────────────────────────────────────────────────
|
||||||
|
|
||||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
public async Task<JoinOutcome> JoinChannelAsync(string channelName, string? password = null)
|
||||||
{
|
{
|
||||||
if (_connection is null) throw new InvalidOperationException("Not connected");
|
if (_connection is null) throw new InvalidOperationException("Not connected");
|
||||||
_joinedChannels.Add(channelName);
|
try
|
||||||
return await _connection.JoinChannelAsync(channelName);
|
{
|
||||||
|
var outcome = await _connection.JoinChannelAsync(channelName, password);
|
||||||
|
_joinedChannels.Add(channelName);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
catch (ChannelPasswordRequiredException)
|
||||||
|
{
|
||||||
|
// Not actually joined — don't track, or reconnects would retry a doomed join
|
||||||
|
_joinedChannels.Remove(channelName);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LeaveChannelAsync(string channelName)
|
public async Task LeaveChannelAsync(string channelName)
|
||||||
@@ -196,8 +210,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
_connection?.SendMessageAsync(channel, content)
|
_connection?.SendMessageAsync(channel, content)
|
||||||
?? throw new InvalidOperationException("Not connected");
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
public Task<List<MessageDto>> GetHistoryAsync(string channel) =>
|
public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
|
||||||
_connection?.GetHistoryAsync(channel)
|
_connection?.GetHistoryAsync(channel, count, offset)
|
||||||
?? throw new InvalidOperationException("Not connected");
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) =>
|
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) =>
|
||||||
|
|||||||
@@ -1,14 +1,39 @@
|
|||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Core.Security;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
|
||||||
namespace EchoHub.Client.Services;
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of joining a channel: decrypted history plus, for end-to-end encrypted
|
||||||
|
/// channels, the key envelope needed to unlock the room content key.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record JoinOutcome(List<MessageDto> History, string? EncryptionSalt, string? WrappedRoomKey);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when joining a channel fails because a password is required or incorrect.
|
||||||
|
/// The UI catches this to prompt the user and retry.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChannelPasswordRequiredException : Exception
|
||||||
|
{
|
||||||
|
public string ChannelName { get; }
|
||||||
|
|
||||||
|
public ChannelPasswordRequiredException(string channelName, string message) : base(message)
|
||||||
|
{
|
||||||
|
ChannelName = channelName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class EchoHubConnection : IAsyncDisposable
|
public sealed class EchoHubConnection : IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
public const string LockedMessagePlaceholder =
|
||||||
|
"[encrypted — rejoin this channel with its passphrase to unlock]";
|
||||||
|
|
||||||
private readonly HubConnection _connection;
|
private readonly HubConnection _connection;
|
||||||
private readonly ClientEncryptionService _encryption;
|
private readonly ClientEncryptionService _encryption;
|
||||||
|
private readonly RoomKeyStore _roomKeys;
|
||||||
|
|
||||||
public event Action<MessageDto>? OnMessageReceived;
|
public event Action<MessageDto>? OnMessageReceived;
|
||||||
public event Action<string, string, UserPresenceDto?>? OnUserJoined;
|
public event Action<string, string, UserPresenceDto?>? OnUserJoined;
|
||||||
@@ -26,9 +51,10 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
||||||
|
|
||||||
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption)
|
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption, RoomKeyStore roomKeys)
|
||||||
{
|
{
|
||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
|
_roomKeys = roomKeys;
|
||||||
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
||||||
|
|
||||||
_connection = new HubConnectionBuilder()
|
_connection = new HubConnectionBuilder()
|
||||||
@@ -65,9 +91,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
|
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
|
||||||
{
|
{
|
||||||
// Decrypt message content received from server
|
OnMessageReceived?.Invoke(DecryptMessage(message));
|
||||||
var decrypted = message with { Content = _encryption.Decrypt(message.Content) };
|
|
||||||
OnMessageReceived?.Invoke(decrypted);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
_connection.On<string, string, UserPresenceDto?>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) =>
|
_connection.On<string, string, UserPresenceDto?>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) =>
|
||||||
@@ -134,12 +158,16 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
OnConnectionStateChanged?.Invoke("Disconnected");
|
OnConnectionStateChanged?.Invoke("Disconnected");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
public async Task<JoinOutcome> JoinChannelAsync(string channelName, string? password = null)
|
||||||
{
|
{
|
||||||
var result = await _connection.InvokeAsync<JoinChannelResult>("JoinChannel", channelName);
|
var result = await _connection.InvokeAsync<JoinChannelResult>("JoinChannel", channelName, password);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
if (result.PasswordRequired)
|
||||||
|
throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected.");
|
||||||
throw new InvalidOperationException(result.Error ?? "Failed to join channel.");
|
throw new InvalidOperationException(result.Error ?? "Failed to join channel.");
|
||||||
return DecryptMessages(result.History);
|
}
|
||||||
|
return new JoinOutcome(DecryptMessages(result.History), result.EncryptionSalt, result.WrappedRoomKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LeaveChannelAsync(string channelName)
|
public async Task LeaveChannelAsync(string channelName)
|
||||||
@@ -149,14 +177,17 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task SendMessageAsync(string channelName, string content)
|
public async Task SendMessageAsync(string channelName, string content)
|
||||||
{
|
{
|
||||||
// Encrypt content before sending to server
|
// Room layer first (end-to-end, server can't read), then transport encryption
|
||||||
|
if (_roomKeys.TryGetKey(channelName, out var roomKey))
|
||||||
|
content = RoomCrypto.EncryptText(content, roomKey);
|
||||||
|
|
||||||
var encrypted = _encryption.Encrypt(content);
|
var encrypted = _encryption.Encrypt(content);
|
||||||
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
|
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
|
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
|
||||||
{
|
{
|
||||||
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
|
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count, offset);
|
||||||
return DecryptMessages(messages);
|
return DecryptMessages(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +203,45 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
private List<MessageDto> DecryptMessages(List<MessageDto> messages)
|
private List<MessageDto> DecryptMessages(List<MessageDto> messages)
|
||||||
{
|
{
|
||||||
return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList();
|
return messages.Select(DecryptMessage).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strips the transport encryption, then the room layer for E2E channels, from the
|
||||||
|
/// message content and every attachment preview. Without the room key the content is
|
||||||
|
/// replaced by a locked placeholder — re-fetch history after unlocking to render it.
|
||||||
|
/// </summary>
|
||||||
|
private MessageDto DecryptMessage(MessageDto message)
|
||||||
|
{
|
||||||
|
_roomKeys.TryGetKey(message.ChannelName, out var roomKey);
|
||||||
|
|
||||||
|
var content = DecryptField(message.Content, roomKey) ?? LockedMessagePlaceholder;
|
||||||
|
|
||||||
|
List<AttachmentDto>? attachments = null;
|
||||||
|
if (message.Attachments is { Count: > 0 })
|
||||||
|
{
|
||||||
|
attachments = message.Attachments
|
||||||
|
.Select(a => a with { AsciiPreview = a.AsciiPreview is null ? null : DecryptField(a.AsciiPreview, roomKey) })
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return message with { Content = content, Attachments = attachments };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decrypts one field: strips transport encryption, then the room layer if it is room
|
||||||
|
/// ciphertext. Returns null when it is room ciphertext but the room key is missing/wrong.
|
||||||
|
/// </summary>
|
||||||
|
private string? DecryptField(string value, byte[]? roomKey)
|
||||||
|
{
|
||||||
|
var plain = _encryption.Decrypt(value);
|
||||||
|
if (!RoomCrypto.IsRoomCiphertext(plain))
|
||||||
|
return plain;
|
||||||
|
|
||||||
|
if (roomKey is not null && RoomCrypto.TryDecryptText(plain, roomKey, out var decrypted))
|
||||||
|
return decrypted;
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
public enum PickerOutcome
|
||||||
|
{
|
||||||
|
/// <summary>The user picked a folder (<see cref="FolderPickResult.Path"/> is set).</summary>
|
||||||
|
Chosen,
|
||||||
|
|
||||||
|
/// <summary>The native dialog ran but the user cancelled it.</summary>
|
||||||
|
Cancelled,
|
||||||
|
|
||||||
|
/// <summary>No native picker is available on this machine (headless, missing tool, etc.).</summary>
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record FolderPickResult(PickerOutcome Outcome, string? Path);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the OS-native folder chooser (Windows Explorer, macOS Finder, Linux GTK/KDE) by shelling
|
||||||
|
/// out, so the TUI doesn't need a GUI toolkit reference. Returns <see cref="PickerOutcome.Unavailable"/>
|
||||||
|
/// when no native dialog can run, so callers can fall back to a configured path.
|
||||||
|
/// </summary>
|
||||||
|
public static class NativeFolderPicker
|
||||||
|
{
|
||||||
|
private const string Title = "Choose your EchoHub download folder";
|
||||||
|
|
||||||
|
public static async Task<FolderPickResult> PickFolderAsync(string? initialDir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
return await PickWindowsAsync(initialDir);
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
return await PickMacAsync();
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
return await PickLinuxAsync(initialDir);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Native folder picker failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FolderPickResult(PickerOutcome.Unavailable, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<FolderPickResult> PickWindowsAsync(string? initialDir)
|
||||||
|
{
|
||||||
|
var safeInit = (initialDir ?? string.Empty).Replace("'", "''");
|
||||||
|
var script = $$"""
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
$d = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||||
|
$d.Description = '{{Title}}'
|
||||||
|
$d.ShowNewFolderButton = $true
|
||||||
|
$d.SelectedPath = '{{safeInit}}'
|
||||||
|
if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }
|
||||||
|
""";
|
||||||
|
|
||||||
|
// -EncodedCommand avoids all quoting issues; FolderBrowserDialog needs an STA thread.
|
||||||
|
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script));
|
||||||
|
var (started, _, stdout) = await RunAsync("powershell.exe",
|
||||||
|
["-STA", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
|
||||||
|
|
||||||
|
if (!started)
|
||||||
|
return new FolderPickResult(PickerOutcome.Unavailable, null);
|
||||||
|
return string.IsNullOrWhiteSpace(stdout)
|
||||||
|
? new FolderPickResult(PickerOutcome.Cancelled, null)
|
||||||
|
: new FolderPickResult(PickerOutcome.Chosen, stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<FolderPickResult> PickMacAsync()
|
||||||
|
{
|
||||||
|
var (started, exit, stdout) = await RunAsync("osascript",
|
||||||
|
["-e", $"POSIX path of (choose folder with prompt \"{Title}\")"]);
|
||||||
|
|
||||||
|
if (!started)
|
||||||
|
return new FolderPickResult(PickerOutcome.Unavailable, null);
|
||||||
|
return exit == 0 && !string.IsNullOrWhiteSpace(stdout)
|
||||||
|
? new FolderPickResult(PickerOutcome.Chosen, stdout)
|
||||||
|
: new FolderPickResult(PickerOutcome.Cancelled, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<FolderPickResult> PickLinuxAsync(string? initialDir)
|
||||||
|
{
|
||||||
|
// No graphical session → no native picker.
|
||||||
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY"))
|
||||||
|
&& string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")))
|
||||||
|
return new FolderPickResult(PickerOutcome.Unavailable, null);
|
||||||
|
|
||||||
|
var zenityArgs = new List<string> { "--file-selection", "--directory", $"--title={Title}" };
|
||||||
|
if (!string.IsNullOrWhiteSpace(initialDir))
|
||||||
|
zenityArgs.Add($"--filename={initialDir!.TrimEnd('/')}/");
|
||||||
|
|
||||||
|
var (zStarted, zExit, zOut) = await RunAsync("zenity", zenityArgs);
|
||||||
|
if (zStarted)
|
||||||
|
return zExit == 0 && !string.IsNullOrWhiteSpace(zOut)
|
||||||
|
? new FolderPickResult(PickerOutcome.Chosen, zOut)
|
||||||
|
: new FolderPickResult(PickerOutcome.Cancelled, null);
|
||||||
|
|
||||||
|
var (kStarted, kExit, kOut) = await RunAsync("kdialog",
|
||||||
|
["--getexistingdirectory", string.IsNullOrWhiteSpace(initialDir) ? "." : initialDir!]);
|
||||||
|
if (kStarted)
|
||||||
|
return kExit == 0 && !string.IsNullOrWhiteSpace(kOut)
|
||||||
|
? new FolderPickResult(PickerOutcome.Chosen, kOut)
|
||||||
|
: new FolderPickResult(PickerOutcome.Cancelled, null);
|
||||||
|
|
||||||
|
return new FolderPickResult(PickerOutcome.Unavailable, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(bool Started, int ExitCode, string StdOut)> RunAsync(string fileName, IEnumerable<string> args)
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo(fileName)
|
||||||
|
{
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
foreach (var arg in args)
|
||||||
|
psi.ArgumentList.Add(arg);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.Start(psi);
|
||||||
|
if (process is null)
|
||||||
|
return (false, -1, string.Empty);
|
||||||
|
|
||||||
|
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||||
|
await process.WaitForExitAsync();
|
||||||
|
return (true, process.ExitCode, stdout.Trim());
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException)
|
||||||
|
{
|
||||||
|
// Executable not found on PATH → treat as "no native picker".
|
||||||
|
return (false, -1, string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,11 @@ namespace EchoHub.Client.Services;
|
|||||||
|
|
||||||
public class NotificationSoundService
|
public class NotificationSoundService
|
||||||
{
|
{
|
||||||
|
// Safety net: if PlaybackFinished never fires we don't want to block future notifications forever.
|
||||||
|
private static readonly TimeSpan PlaybackTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
private readonly Player _player = new();
|
private readonly Player _player = new();
|
||||||
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
private readonly NotificationConfig _config;
|
private readonly NotificationConfig _config;
|
||||||
private string? _resolvedSoundPath;
|
private string? _resolvedSoundPath;
|
||||||
|
|
||||||
@@ -41,18 +45,31 @@ public class NotificationSoundService
|
|||||||
|
|
||||||
private async Task PlayInternal()
|
private async Task PlayInternal()
|
||||||
{
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
|
|
||||||
|
// _player.Play returns as soon as playback starts, so we wait on PlaybackFinished
|
||||||
|
// to hold the lock for the duration of the sound. A one-shot handler + timeout
|
||||||
|
// keeps the finally release robust: never-fires → timeout; fires twice → ignored
|
||||||
|
// (TrySetResult); handler throws → caller's catch still runs finally.
|
||||||
|
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
void OnFinished(object? s, EventArgs e) => completion.TrySetResult();
|
||||||
|
_player.PlaybackFinished += OnFinished;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_player.Playing)
|
|
||||||
await _player.Stop();
|
|
||||||
|
|
||||||
await _player.SetVolume(_config.Volume);
|
await _player.SetVolume(_config.Volume);
|
||||||
await _player.Play(_resolvedSoundPath!);
|
await _player.Play(_resolvedSoundPath!);
|
||||||
|
await Task.WhenAny(completion.Task, Task.Delay(PlaybackTimeout));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to play notification sound");
|
Log.Warning(ex, "Failed to play notification sound");
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_player.PlaybackFinished -= OnFinished;
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ResolveSoundPath()
|
private void ResolveSoundPath()
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One file to upload as part of a message. For end-to-end encrypted channels the stream
|
||||||
|
/// is already ciphertext, <see cref="DeclaredKind"/> is set (image/audio/file), and
|
||||||
|
/// <see cref="EncryptedPreview"/> holds the room-encrypted ASCII art for images.
|
||||||
|
/// For normal channels only <see cref="Stream"/> and <see cref="FileName"/> are set.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record OutgoingAttachment(
|
||||||
|
Stream Stream,
|
||||||
|
string FileName,
|
||||||
|
string? DeclaredKind = null,
|
||||||
|
string? EncryptedPreview = null);
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using EchoHub.Client.Config;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds room content keys for end-to-end encrypted channels: in-memory for the
|
||||||
|
/// active session, persisted per-server in the client config (like saved sessions)
|
||||||
|
/// so users don't retype the passphrase every launch. Keys never leave this machine.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoomKeyStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, byte[]> _keys = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Lock _lock = new();
|
||||||
|
private string? _serverUrl;
|
||||||
|
|
||||||
|
/// <summary>Binds the store to a server and loads that server's cached keys from config.</summary>
|
||||||
|
public void LoadForServer(string serverUrl)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_serverUrl = serverUrl;
|
||||||
|
_keys.Clear();
|
||||||
|
|
||||||
|
var server = FindServer(ConfigManager.Load(), serverUrl);
|
||||||
|
if (server is null) return;
|
||||||
|
|
||||||
|
foreach (var (channel, base64) in server.ChannelKeys)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_keys[channel] = Convert.FromBase64String(base64);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
Log.Warning("Ignoring malformed cached room key for #{Channel}", channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetKey(string channelName, out byte[] key)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_keys.TryGetValue(channelName, out var k))
|
||||||
|
{
|
||||||
|
key = k;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
key = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasKey(string channelName) => TryGetKey(channelName, out _);
|
||||||
|
|
||||||
|
/// <summary>Stores a key for the session and persists it to the server's config entry.</summary>
|
||||||
|
public void StoreKey(string channelName, byte[] key)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_keys[channelName] = key;
|
||||||
|
Persist(server => server.ChannelKeys[channelName] = Convert.ToBase64String(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveKey(string channelName)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_keys.Remove(channelName);
|
||||||
|
Persist(server => server.ChannelKeys.Remove(channelName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_keys.Clear();
|
||||||
|
_serverUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Persist(Action<SavedServer> mutate)
|
||||||
|
{
|
||||||
|
if (_serverUrl is null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = ConfigManager.Load();
|
||||||
|
var server = FindServer(config, _serverUrl);
|
||||||
|
if (server is null) return; // server not saved yet — key stays in-memory only
|
||||||
|
|
||||||
|
mutate(server);
|
||||||
|
ConfigManager.Save(config);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to persist room key cache");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SavedServer? FindServer(ClientConfig config, string url) =>
|
||||||
|
config.SavedServers.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
@@ -40,7 +40,37 @@ public static class UpdateBackupService
|
|||||||
|
|
||||||
Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version);
|
Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version);
|
||||||
|
|
||||||
ZipFile.CreateFromDirectory(appDir, BackupZipPath, CompressionLevel.Fastest, includeBaseDirectory: false);
|
using (var archive = ZipFile.Open(BackupZipPath, ZipArchiveMode.Create))
|
||||||
|
{
|
||||||
|
foreach (var file in Directory.EnumerateFiles(appDir, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var relativePath = Path.GetRelativePath(appDir, file);
|
||||||
|
|
||||||
|
// Skip log files to prevent locking errors with Serilog while zipping
|
||||||
|
if (relativePath.StartsWith("logs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
relativePath.StartsWith("logs" + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
relativePath.EndsWith(".log", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize path separators for the zip archive format
|
||||||
|
var entryName = relativePath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/');
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
archive.CreateEntryFromFile(file, entryName, CompressionLevel.Fastest);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Skipped locked file {FileName} during backup calculation", relativePath);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Skipped inaccessible file {FileName} during backup calculation", relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var info = new BackupInfo(version, appDir, DateTimeOffset.UtcNow);
|
var info = new BackupInfo(version, appDir, DateTimeOffset.UtcNow);
|
||||||
var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo);
|
var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
// Create backup before the update starts
|
// Create backup before the update starts
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Creating backup..."));
|
_progressDialog?.UpdateProgress(0f, "Creating backup...");
|
||||||
UpdateBackupService.CreateBackup();
|
UpdateBackupService.CreateBackup();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -79,27 +79,21 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
Log.Error(ex, "Failed to create pre-update backup");
|
Log.Error(ex, "Failed to create pre-update backup");
|
||||||
|
|
||||||
var proceed = false;
|
var proceed = false;
|
||||||
_app.Invoke(() =>
|
proceed = MessageBox.Query(
|
||||||
{
|
_app,
|
||||||
proceed = MessageBox.Query(
|
"Backup Warning",
|
||||||
_app,
|
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
|
||||||
"Backup Warning",
|
"Continue", "Cancel") == 0;
|
||||||
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
|
|
||||||
"Continue", "Cancel") == 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!proceed)
|
if (!proceed)
|
||||||
{
|
{
|
||||||
_app.Invoke(() =>
|
_progressDialog?.Close();
|
||||||
{
|
_progressDialog = null;
|
||||||
_progressDialog?.Close();
|
|
||||||
_progressDialog = null;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Downloading update..."));
|
_progressDialog?.UpdateProgress(0f, "Downloading update...");
|
||||||
await _updater.UpdateAsync();
|
await _updater.UpdateAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -445,10 +445,44 @@ public static class ThemeManager
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly Theme TransparentLightTheme = new()
|
||||||
|
{
|
||||||
|
Name = "TransparentLight",
|
||||||
|
Base = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "Black",
|
||||||
|
Background = "None",
|
||||||
|
FocusForeground = "Blue",
|
||||||
|
FocusBackground = "None"
|
||||||
|
},
|
||||||
|
Menu = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "Black",
|
||||||
|
Background = "None",
|
||||||
|
FocusForeground = "Blue",
|
||||||
|
FocusBackground = "None"
|
||||||
|
},
|
||||||
|
Dialog = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "Black",
|
||||||
|
Background = "Gray",
|
||||||
|
FocusForeground = "Blue",
|
||||||
|
FocusBackground = "White"
|
||||||
|
},
|
||||||
|
Status = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "DarkGray",
|
||||||
|
Background = "None",
|
||||||
|
FocusForeground = "DarkGray",
|
||||||
|
FocusBackground = "None"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private static readonly List<Theme> BuiltInThemes =
|
private static readonly List<Theme> BuiltInThemes =
|
||||||
[
|
[
|
||||||
DefaultTheme,
|
DefaultTheme,
|
||||||
TransparentTheme,
|
TransparentTheme,
|
||||||
|
TransparentLightTheme,
|
||||||
ClassicTheme,
|
ClassicTheme,
|
||||||
LightTheme,
|
LightTheme,
|
||||||
HackerTheme,
|
HackerTheme,
|
||||||
@@ -541,7 +575,14 @@ public static class ThemeManager
|
|||||||
Focus = focus,
|
Focus = focus,
|
||||||
HotNormal = normal,
|
HotNormal = normal,
|
||||||
HotFocus = focus,
|
HotFocus = focus,
|
||||||
Disabled = normal
|
Disabled = normal,
|
||||||
|
|
||||||
|
// TextView/TextField draw their editable area with the Editable/ReadOnly roles. If
|
||||||
|
// left unset, Terminal.Gui derives an opaque background from Normal — which renders
|
||||||
|
// as a solid box behind the input under transparent themes. Pin them to the theme's
|
||||||
|
// own colors so the input matches its background (transparent stays transparent).
|
||||||
|
Editable = normal,
|
||||||
|
ReadOnly = normal
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public partial class ChatLine
|
|||||||
public bool IsMention { get; set; }
|
public bool IsMention { get; set; }
|
||||||
public string? AttachmentUrl { get; set; }
|
public string? AttachmentUrl { get; set; }
|
||||||
public string? AttachmentFileName { get; set; }
|
public string? AttachmentFileName { get; set; }
|
||||||
public MessageType? Type { get; set; }
|
public AttachmentKind? AttachmentKind { get; set; }
|
||||||
public string? SenderUsername { get; set; }
|
public string? SenderUsername { get; set; }
|
||||||
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
||||||
public int ContinuationIndent { get; set; }
|
public int ContinuationIndent { get; set; }
|
||||||
@@ -124,7 +124,7 @@ public partial class ChatLine
|
|||||||
{
|
{
|
||||||
wrapped.AttachmentUrl = AttachmentUrl;
|
wrapped.AttachmentUrl = AttachmentUrl;
|
||||||
wrapped.AttachmentFileName = AttachmentFileName;
|
wrapped.AttachmentFileName = AttachmentFileName;
|
||||||
wrapped.Type = Type;
|
wrapped.AttachmentKind = AttachmentKind;
|
||||||
wrapped.MessageId = MessageId;
|
wrapped.MessageId = MessageId;
|
||||||
wrapped.SenderUsername = SenderUsername;
|
wrapped.SenderUsername = SenderUsername;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ public class ChatListSource : IListDataSource
|
|||||||
|
|
||||||
var chatLine = _lines[item];
|
var chatLine = _lines[item];
|
||||||
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
|
||||||
|
// Highlight the selected row (whole width) while the list has focus — used by the
|
||||||
|
// F6 selection flow and right-click. The custom source must draw this itself.
|
||||||
|
var focusAttr = selected && listView.HasFocus
|
||||||
|
? listView.GetAttributeForRole(VisualRole.Focus)
|
||||||
|
: (Attribute?)null;
|
||||||
|
|
||||||
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
|
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
|
||||||
|
|
||||||
int charPos = 0;
|
int charPos = 0;
|
||||||
@@ -72,11 +79,19 @@ public class ChatListSource : IListDataSource
|
|||||||
|
|
||||||
foreach (var segment in chatLine.Segments)
|
foreach (var segment in chatLine.Segments)
|
||||||
{
|
{
|
||||||
var attr = segment.Color ?? normalAttr;
|
Attribute attr;
|
||||||
if (attr.Background == Color.None)
|
if (focusAttr is { } focus)
|
||||||
attr = attr with { Background = normalAttr.Background };
|
{
|
||||||
if (mentionBg.HasValue)
|
attr = focus;
|
||||||
attr = attr with { Background = mentionBg.Value };
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
attr = segment.Color ?? normalAttr;
|
||||||
|
if (attr.Background == Color.None)
|
||||||
|
attr = attr with { Background = normalAttr.Background };
|
||||||
|
if (mentionBg.HasValue)
|
||||||
|
attr = attr with { Background = mentionBg.Value };
|
||||||
|
}
|
||||||
listView.SetAttribute(attr);
|
listView.SetAttribute(attr);
|
||||||
|
|
||||||
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
|
||||||
@@ -91,7 +106,8 @@ public class ChatListSource : IListDataSource
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
|
var fillAttr = focusAttr
|
||||||
|
?? (mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr);
|
||||||
listView.SetAttribute(fillAttr);
|
listView.SetAttribute(fillAttr);
|
||||||
for (int i = drawnChars; i < width; i++)
|
for (int i = drawnChars; i < width; i++)
|
||||||
listView.AddStr(" ");
|
listView.AddStr(" ");
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ public sealed class ChatMessageManager
|
|||||||
_channelMessages[channelName] = messages;
|
_channelMessages[channelName] = messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
var time = DateTimeOffset.Now.ToString("HH:mm");
|
var time = FormatDateTime(DateTimeOffset.Now);
|
||||||
var textLines = text.Split('\n');
|
var textLines = text.Split('\n');
|
||||||
|
|
||||||
messages.Add(new ChatLine(
|
messages.Add(new ChatLine(
|
||||||
@@ -130,7 +130,7 @@ public sealed class ChatMessageManager
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void AddStatusMessage(string channelName, string username, string status)
|
public void AddStatusMessage(string channelName, string username, string status)
|
||||||
{
|
{
|
||||||
var time = DateTimeOffset.Now.ToString("HH:mm");
|
var time = FormatDateTime(DateTimeOffset.Now);
|
||||||
var segments = new List<ChatSegment>
|
var segments = new List<ChatSegment>
|
||||||
{
|
{
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
@@ -186,6 +186,39 @@ public sealed class ChatMessageManager
|
|||||||
MessagesChanged?.Invoke(channelName);
|
MessagesChanged?.Invoke(channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prepend older messages at the front of a channel's buffer, skipping any that are already present.
|
||||||
|
/// Fires <see cref="HistoryPrepended"/> when new lines are actually inserted.
|
||||||
|
/// </summary>
|
||||||
|
public void PrependHistory(string channelName, List<MessageDto> olderMessages)
|
||||||
|
{
|
||||||
|
if (!_channelMessages.TryGetValue(channelName, out var existing))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var existingIds = existing
|
||||||
|
.Where(l => l.MessageId.HasValue)
|
||||||
|
.Select(l => l.MessageId!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var newLines = olderMessages
|
||||||
|
.Where(m => !existingIds.Contains(m.Id))
|
||||||
|
.SelectMany(FormatMessage)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (newLines.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
existing.InsertRange(0, newLines);
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
HistoryPrepended?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired after older messages are prepended to a channel's buffer. Parameter is the channel name.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string>? HistoryPrepended;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reset all message state (used on disconnect).
|
/// Reset all message state (used on disconnect).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -201,74 +234,81 @@ public sealed class ChatMessageManager
|
|||||||
|
|
||||||
private List<ChatLine> FormatMessage(MessageDto message)
|
private List<ChatLine> FormatMessage(MessageDto message)
|
||||||
{
|
{
|
||||||
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
var time = FormatDateTime(message.SentAt);
|
||||||
var senderName = message.SenderUsername + ":";
|
var senderName = message.SenderUsername + ":";
|
||||||
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
||||||
|
|
||||||
|
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
||||||
|
var pad = new string(' ', 7);
|
||||||
|
|
||||||
var lines = new List<ChatLine>();
|
var lines = new List<ChatLine>();
|
||||||
|
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
|
||||||
|
var attachments = message.Attachments ?? [];
|
||||||
|
|
||||||
switch (message.Type)
|
// Header line: caption text, or a summary when the message is attachments-only
|
||||||
|
if (hasContent)
|
||||||
{
|
{
|
||||||
case MessageType.Image:
|
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||||
lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
|
var contentLines = displayContent.Split('\n');
|
||||||
if (!string.IsNullOrWhiteSpace(message.Content))
|
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {contentLines[0].TrimEnd('\r')}"));
|
||||||
{
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
foreach (var artLine in message.Content.Split('\n'))
|
lines.Add(new ChatLine(ChatColors.SplitMentions($"{indent}{contentLines[i].TrimEnd('\r')}")));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var summary = attachments.Count switch
|
||||||
|
{
|
||||||
|
0 => " ",
|
||||||
|
1 => $" [{attachments[0].Kind.ToString().ToLowerInvariant()}]",
|
||||||
|
_ => $" [{attachments.Count} attachments]",
|
||||||
|
};
|
||||||
|
lines.Add(BuildChatLine(time, senderName, senderColor, summary));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var l in lines)
|
||||||
|
l.ContinuationIndent = indent.Length;
|
||||||
|
|
||||||
|
// One block per attachment
|
||||||
|
foreach (var attachment in attachments)
|
||||||
|
{
|
||||||
|
switch (attachment.Kind)
|
||||||
|
{
|
||||||
|
case Core.Models.AttachmentKind.Image:
|
||||||
|
if (!string.IsNullOrWhiteSpace(attachment.AsciiPreview))
|
||||||
{
|
{
|
||||||
var trimmed = artLine.TrimEnd('\r');
|
foreach (var artLine in attachment.AsciiPreview.Split('\n'))
|
||||||
if (ChatLine.HasColorTags(trimmed))
|
{
|
||||||
lines.Add(ChatLine.FromColoredText(" " + trimmed));
|
var trimmed = artLine.TrimEnd('\r');
|
||||||
else
|
lines.Add(ChatLine.HasColorTags(trimmed)
|
||||||
lines.Add(new ChatLine($" {trimmed}"));
|
? ChatLine.FromColoredText(pad + trimmed)
|
||||||
|
: new ChatLine($"{pad}{trimmed}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
lines.Add(AttachmentActionLine(pad,
|
||||||
break;
|
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
|
||||||
|
ChatColors.FileAttr, attachment));
|
||||||
|
break;
|
||||||
|
|
||||||
case MessageType.Audio:
|
case Core.Models.AttachmentKind.Audio:
|
||||||
var audioName = message.AttachmentFileName ?? "unknown";
|
lines.Add(AttachmentActionLine(pad,
|
||||||
var audioSize = FormatFileSize(message.AttachmentFileSize);
|
$"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||||
var audioLine = BuildChatLineColored(time, senderName, senderColor,
|
ChatColors.AudioAttr, attachment));
|
||||||
$" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
|
break;
|
||||||
audioLine.AttachmentUrl = message.AttachmentUrl;
|
|
||||||
audioLine.AttachmentFileName = audioName;
|
|
||||||
audioLine.Type = MessageType.Audio;
|
|
||||||
lines.Add(audioLine);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case MessageType.File:
|
default:
|
||||||
var fileName = message.AttachmentFileName ?? "unknown";
|
lines.Add(AttachmentActionLine(pad,
|
||||||
var fileSize = FormatFileSize(message.AttachmentFileSize);
|
$"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||||
var fileLine = BuildChatLineColored(time, senderName, senderColor,
|
ChatColors.FileAttr, attachment));
|
||||||
$" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
|
break;
|
||||||
fileLine.AttachmentUrl = message.AttachmentUrl;
|
}
|
||||||
fileLine.AttachmentFileName = fileName;
|
}
|
||||||
fileLine.Type = MessageType.File;
|
|
||||||
lines.Add(fileLine);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case MessageType.Text:
|
// Link embeds (from caption URLs)
|
||||||
default:
|
if (message.Embeds is { Count: > 0 })
|
||||||
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
{
|
||||||
var contentLines = displayContent.Split('\n');
|
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||||
var firstLine = contentLines[0].TrimEnd('\r');
|
foreach (var embed in message.Embeds)
|
||||||
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
|
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
||||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
|
||||||
for (int i = 1; i < contentLines.Length; i++)
|
|
||||||
{
|
|
||||||
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
|
|
||||||
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var l in lines)
|
|
||||||
l.ContinuationIndent = indent.Length;
|
|
||||||
|
|
||||||
if (message.Embeds is { Count: > 0 })
|
|
||||||
{
|
|
||||||
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
|
||||||
foreach (var embed in message.Embeds)
|
|
||||||
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
@@ -277,7 +317,7 @@ public sealed class ChatMessageManager
|
|||||||
line.SenderUsername = message.SenderUsername;
|
line.SenderUsername = message.SenderUsername;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
if (hasContent && !string.IsNullOrEmpty(_currentUser))
|
||||||
{
|
{
|
||||||
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||||
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
||||||
@@ -290,6 +330,23 @@ public sealed class ChatMessageManager
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
||||||
|
/// route activation (play audio, download file, save original image).
|
||||||
|
/// </summary>
|
||||||
|
private static ChatLine AttachmentActionLine(string pad, string text, Attribute color, AttachmentDto attachment)
|
||||||
|
{
|
||||||
|
var line = new ChatLine(new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new(pad, null),
|
||||||
|
new(text, color),
|
||||||
|
});
|
||||||
|
line.AttachmentUrl = attachment.Url;
|
||||||
|
line.AttachmentFileName = attachment.FileName;
|
||||||
|
line.AttachmentKind = attachment.Kind;
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
|
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
|
||||||
{
|
{
|
||||||
var segments = new List<ChatSegment>
|
var segments = new List<ChatSegment>
|
||||||
@@ -392,6 +449,14 @@ public sealed class ChatMessageManager
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string FormatDateTime(DateTimeOffset timestamp)
|
||||||
|
{
|
||||||
|
if (timestamp.Date == DateTimeOffset.Now.Date)
|
||||||
|
return timestamp.ToLocalTime().ToString("t");
|
||||||
|
else
|
||||||
|
return timestamp.ToLocalTime().ToString("g");
|
||||||
|
}
|
||||||
|
|
||||||
internal static string FormatFileSize(long? bytes)
|
internal static string FormatFileSize(long? bytes)
|
||||||
{
|
{
|
||||||
if (bytes is null or 0)
|
if (bytes is null or 0)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prompts for a channel password when joining a protected channel.
|
||||||
|
/// Returns the entered password, or null if the user cancels.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChannelPasswordDialog
|
||||||
|
{
|
||||||
|
public static string? Show(IApplication app, string channelName, string? message = null)
|
||||||
|
{
|
||||||
|
string? result = null;
|
||||||
|
|
||||||
|
var dialog = new Dialog { Title = $"Join #{channelName}", Width = 50, Height = 10, CommandsToBubbleUp = [] };
|
||||||
|
|
||||||
|
var infoLabel = new Label
|
||||||
|
{
|
||||||
|
Text = message ?? $"#{channelName} is password protected.",
|
||||||
|
X = 1,
|
||||||
|
Y = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
var passwordLabel = new Label { Text = "Password:", X = 1, Y = 3 };
|
||||||
|
var passwordField = new TextField { X = 11, Y = 3, Width = Dim.Fill(2), Secret = true };
|
||||||
|
|
||||||
|
var joinButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Join",
|
||||||
|
IsDefault = true,
|
||||||
|
X = Pos.Center() - 9,
|
||||||
|
Y = 5
|
||||||
|
};
|
||||||
|
|
||||||
|
var cancelButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Cancel",
|
||||||
|
X = Pos.Center() + 2,
|
||||||
|
Y = 5
|
||||||
|
};
|
||||||
|
|
||||||
|
joinButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
var password = passwordField.Text;
|
||||||
|
if (string.IsNullOrEmpty(password))
|
||||||
|
{
|
||||||
|
MessageBox.ErrorQuery(app, "Error", "Password is required.", "OK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = password;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.Add(infoLabel, passwordLabel, passwordField, joinButton, cancelButton);
|
||||||
|
|
||||||
|
passwordField.SetFocus();
|
||||||
|
app.Run(dialog);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ using Terminal.Gui.ViewBase;
|
|||||||
|
|
||||||
namespace EchoHub.Client.UI.Dialogs;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
|
public record CreateChannelResult(string Name, string? Topic, bool IsPublic, string? Password);
|
||||||
|
|
||||||
public sealed class CreateChannelDialog
|
public sealed class CreateChannelDialog
|
||||||
{
|
{
|
||||||
@@ -12,7 +12,7 @@ public sealed class CreateChannelDialog
|
|||||||
{
|
{
|
||||||
CreateChannelResult? result = null;
|
CreateChannelResult? result = null;
|
||||||
|
|
||||||
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14, CommandsToBubbleUp = [] };
|
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 16, CommandsToBubbleUp = [] };
|
||||||
|
|
||||||
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
|
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
|
||||||
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
|
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
|
||||||
@@ -20,19 +20,22 @@ public sealed class CreateChannelDialog
|
|||||||
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
|
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
|
||||||
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
|
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
|
||||||
|
|
||||||
|
var passwordLabel = new Label { Text = "Password:", X = 1, Y = 5 };
|
||||||
|
var passwordField = new TextField { X = 11, Y = 5, Width = Dim.Fill(2), Secret = true };
|
||||||
|
|
||||||
var publicCheckbox = new CheckBox
|
var publicCheckbox = new CheckBox
|
||||||
{
|
{
|
||||||
Text = "Public (visible to all users)",
|
Text = "Public (visible to all users)",
|
||||||
X = 1,
|
X = 1,
|
||||||
Y = 5,
|
Y = 7,
|
||||||
Value = CheckState.Checked
|
Value = CheckState.Checked
|
||||||
};
|
};
|
||||||
|
|
||||||
var hintLabel = new Label
|
var hintLabel = new Label
|
||||||
{
|
{
|
||||||
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
|
Text = "Name: a-z, 0-9, -, _ (2-100 chars). Empty password = open channel.",
|
||||||
X = 1,
|
X = 1,
|
||||||
Y = 7,
|
Y = 9,
|
||||||
};
|
};
|
||||||
|
|
||||||
var createButton = new Button
|
var createButton = new Button
|
||||||
@@ -40,14 +43,14 @@ public sealed class CreateChannelDialog
|
|||||||
Text = "Create",
|
Text = "Create",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 10,
|
X = Pos.Center() - 10,
|
||||||
Y = 9
|
Y = 11
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 5,
|
X = Pos.Center() + 5,
|
||||||
Y = 9
|
Y = 11
|
||||||
};
|
};
|
||||||
|
|
||||||
createButton.Accepting += (s, e) =>
|
createButton.Accepting += (s, e) =>
|
||||||
@@ -63,8 +66,12 @@ public sealed class CreateChannelDialog
|
|||||||
if (string.IsNullOrWhiteSpace(topic))
|
if (string.IsNullOrWhiteSpace(topic))
|
||||||
topic = null;
|
topic = null;
|
||||||
|
|
||||||
|
var password = passwordField.Text;
|
||||||
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
password = null;
|
||||||
|
|
||||||
var isPublic = publicCheckbox.Value == CheckState.Checked;
|
var isPublic = publicCheckbox.Value == CheckState.Checked;
|
||||||
result = new CreateChannelResult(name, topic, isPublic);
|
result = new CreateChannelResult(name, topic, isPublic, password);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -76,7 +83,8 @@ public sealed class CreateChannelDialog
|
|||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
|
|
||||||
dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton);
|
dialog.Add(nameLabel, nameField, topicLabel, topicField, passwordLabel, passwordField,
|
||||||
|
publicCheckbox, hintLabel, createButton, cancelButton);
|
||||||
|
|
||||||
nameField.SetFocus();
|
nameField.SetFocus();
|
||||||
app.Run(dialog);
|
app.Run(dialog);
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using EchoHub.Client.UI.ListSources;
|
||||||
|
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Input;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
|
public enum SearchResultType
|
||||||
|
{
|
||||||
|
Channel,
|
||||||
|
Action
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SearchResult(SearchResultType Type, string Key, string Label);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command-palette style search dialog (Ctrl+K) for navigating channels and triggering app actions.
|
||||||
|
/// </summary>
|
||||||
|
public static class SearchDialog
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyList<SearchResult> DefaultActions = [
|
||||||
|
new(SearchResultType.Action, "connect", "Connect to Server"),
|
||||||
|
new(SearchResultType.Action, "disconnect", "Disconnect"),
|
||||||
|
new(SearchResultType.Action, "logout", "Logout"),
|
||||||
|
new(SearchResultType.Action, "profile", "My Profile"),
|
||||||
|
new(SearchResultType.Action, "status", "Set Status"),
|
||||||
|
new(SearchResultType.Action, "create-channel", "Create Channel"),
|
||||||
|
new(SearchResultType.Action, "delete-channel", "Delete Channel"),
|
||||||
|
new(SearchResultType.Action, "servers", "Saved Servers"),
|
||||||
|
new(SearchResultType.Action, "toggle-users", "Toggle Users Panel"),
|
||||||
|
new(SearchResultType.Action, "updates", "Check for Updates"),
|
||||||
|
new(SearchResultType.Action, "quit", "Quit"),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static SearchResult? Show(IApplication app, IReadOnlyList<string> channels)
|
||||||
|
{
|
||||||
|
SearchResult? result = null;
|
||||||
|
var source = new SearchListSource(BuildAllItems(channels));
|
||||||
|
|
||||||
|
var dialog = new Dialog
|
||||||
|
{
|
||||||
|
Title = "Search",
|
||||||
|
Width = 59,
|
||||||
|
Height = 22,
|
||||||
|
};
|
||||||
|
|
||||||
|
var hintLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Channels and actions \u2502 \u2193 to navigate \u2502 Enter to select",
|
||||||
|
X = 1,
|
||||||
|
Y = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
var searchField = new TextField
|
||||||
|
{
|
||||||
|
X = 1,
|
||||||
|
Y = 2,
|
||||||
|
Title = "Search",
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
};
|
||||||
|
|
||||||
|
var resultList = new ListView
|
||||||
|
{
|
||||||
|
X = 1,
|
||||||
|
Y = 4,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Height = Dim.Fill(3),
|
||||||
|
Source = source
|
||||||
|
};
|
||||||
|
|
||||||
|
var cancelButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Cancel",
|
||||||
|
X = Pos.Center(),
|
||||||
|
Y = Pos.AnchorEnd(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (source.Count > 0)
|
||||||
|
resultList.SelectedItem = 0;
|
||||||
|
|
||||||
|
searchField.KeyDown += (s, e) =>
|
||||||
|
{
|
||||||
|
if (e.KeyCode == Key.K.WithCtrl)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
searchField.TextChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
source.Filter(searchField.Text ?? string.Empty);
|
||||||
|
resultList.Source = source;
|
||||||
|
if (source.Count > 0)
|
||||||
|
resultList.SelectedItem = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
searchField.Accepting += (s, e) => TryConfirm(e);
|
||||||
|
|
||||||
|
resultList.Accepting += (s, e) => TryConfirm(e);
|
||||||
|
|
||||||
|
resultList.KeystrokeNavigator.SearchStringChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
searchField.SetFocus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.KeyDown += (s, e) =>
|
||||||
|
{
|
||||||
|
if (e.KeyCode == Key.K.WithCtrl)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.Add(hintLabel, searchField, resultList, cancelButton);
|
||||||
|
searchField.SetFocus();
|
||||||
|
app.Run(dialog);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
void TryConfirm(CommandEventArgs e)
|
||||||
|
{
|
||||||
|
var idx = resultList.SelectedItem ?? 0;
|
||||||
|
if (source.Count > 0 && idx >= 0 && idx < source.Count)
|
||||||
|
{
|
||||||
|
result = source.GetItem(idx);
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<SearchResult> BuildAllItems(IReadOnlyList<string> channels)
|
||||||
|
{
|
||||||
|
var items = new List<SearchResult>();
|
||||||
|
foreach (var ch in channels)
|
||||||
|
items.Add(new SearchResult(SearchResultType.Channel, ch, $"#{ch}"));
|
||||||
|
items.AddRange(DefaultActions);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recognizes a dragged-and-dropped file (or files) that a terminal delivers into the input as an
|
||||||
|
/// absolute path. Terminals differ: some paste the whole path at once, others send it character by
|
||||||
|
/// character; either way this checks whether the current input text resolves to existing file(s).
|
||||||
|
/// </summary>
|
||||||
|
public static class DroppedFileParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cheap pre-check so callers only stat the filesystem when the input plausibly holds a path:
|
||||||
|
/// a quoted path, a Windows drive path (<c>X:\</c>/<c>X:/</c>), a UNC path (<c>\\</c>), or a
|
||||||
|
/// POSIX absolute path (<c>/</c>). Normal chat text never starts this way.
|
||||||
|
/// </summary>
|
||||||
|
public static bool LooksLikePath(string text)
|
||||||
|
{
|
||||||
|
var t = text.TrimStart();
|
||||||
|
if (t.Length < 3)
|
||||||
|
return false;
|
||||||
|
if (t[0] is '"' or '/')
|
||||||
|
return true;
|
||||||
|
if (t.StartsWith(@"\\", StringComparison.Ordinal))
|
||||||
|
return true;
|
||||||
|
return char.IsLetter(t[0]) && t[1] == ':' && (t[2] == '\\' || t[2] == '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when <paramref name="text"/> resolves to one or more existing files.
|
||||||
|
/// Handles a single path (quoted or not, possibly containing spaces) and multiple
|
||||||
|
/// space-separated (optionally quoted) paths. <paramref name="fileExists"/> is injectable
|
||||||
|
/// for testing; production passes <see cref="File.Exists"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGetFiles(string text, out List<string> files, Func<string, bool>? fileExists = null)
|
||||||
|
{
|
||||||
|
fileExists ??= File.Exists;
|
||||||
|
files = [];
|
||||||
|
|
||||||
|
var trimmed = text.Trim();
|
||||||
|
if (trimmed.Length < 3 || trimmed.Length > 4096 || trimmed.Contains('\n'))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Single path, possibly quoted and/or containing spaces.
|
||||||
|
var unquoted = StripQuotes(trimmed);
|
||||||
|
if (Path.IsPathFullyQualified(unquoted) && fileExists(unquoted))
|
||||||
|
{
|
||||||
|
files.Add(unquoted);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple files: space-separated tokens, each optionally quoted.
|
||||||
|
foreach (var token in TokenizeQuoted(trimmed))
|
||||||
|
{
|
||||||
|
if (!Path.IsPathFullyQualified(token) || !fileExists(token))
|
||||||
|
{
|
||||||
|
files.Clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
files.Add(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripQuotes(string s) =>
|
||||||
|
s.Length >= 2 && ((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\''))
|
||||||
|
? s[1..^1]
|
||||||
|
: s;
|
||||||
|
|
||||||
|
private static IEnumerable<string> TokenizeQuoted(string input)
|
||||||
|
{
|
||||||
|
var current = new StringBuilder();
|
||||||
|
var quote = '\0';
|
||||||
|
|
||||||
|
foreach (var c in input)
|
||||||
|
{
|
||||||
|
if (quote != '\0')
|
||||||
|
{
|
||||||
|
if (c == quote) quote = '\0';
|
||||||
|
else current.Append(c);
|
||||||
|
}
|
||||||
|
else if (c is '"' or '\'')
|
||||||
|
{
|
||||||
|
quote = c;
|
||||||
|
}
|
||||||
|
else if (c == ' ')
|
||||||
|
{
|
||||||
|
if (current.Length > 0)
|
||||||
|
{
|
||||||
|
yield return current.ToString();
|
||||||
|
current.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Length > 0)
|
||||||
|
yield return current.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ public class ChannelListSource : IListDataSource
|
|||||||
{
|
{
|
||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
private readonly Dictionary<string, int> _unreadCounts = [];
|
private readonly Dictionary<string, int> _unreadCounts = [];
|
||||||
|
private readonly HashSet<string> _protectedChannels = [];
|
||||||
private string _activeChannel = string.Empty;
|
private string _activeChannel = string.Empty;
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
@@ -28,13 +29,17 @@ public class ChannelListSource : IListDataSource
|
|||||||
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
||||||
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
||||||
|
|
||||||
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
|
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
|
||||||
|
IReadOnlySet<string>? protectedChannels = null)
|
||||||
{
|
{
|
||||||
_channelNames.Clear();
|
_channelNames.Clear();
|
||||||
_channelNames.AddRange(channels);
|
_channelNames.AddRange(channels);
|
||||||
_unreadCounts.Clear();
|
_unreadCounts.Clear();
|
||||||
foreach (var kv in unread)
|
foreach (var kv in unread)
|
||||||
_unreadCounts[kv.Key] = kv.Value;
|
_unreadCounts[kv.Key] = kv.Value;
|
||||||
|
_protectedChannels.Clear();
|
||||||
|
if (protectedChannels is not null)
|
||||||
|
_protectedChannels.UnionWith(protectedChannels);
|
||||||
_activeChannel = activeChannel;
|
_activeChannel = activeChannel;
|
||||||
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
||||||
if (!SuspendCollectionChangedEvent)
|
if (!SuspendCollectionChangedEvent)
|
||||||
@@ -57,7 +62,8 @@ public class ChannelListSource : IListDataSource
|
|||||||
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||||
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
||||||
var prefix = isActive ? "> " : " ";
|
var prefix = isActive ? "> " : " ";
|
||||||
var channelText = $"#{name}";
|
// Trailing * marks password-protected (+k) channels
|
||||||
|
var channelText = _protectedChannels.Contains(name) ? $"#{name}*" : $"#{name}";
|
||||||
var badge = hasUnread ? $" ({unread})" : "";
|
var badge = hasUnread ? $" ({unread})" : "";
|
||||||
|
|
||||||
// Resolve Transparent backgrounds to the view's actual background
|
// Resolve Transparent backgrounds to the view's actual background
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using EchoHub.Client.UI.Chat;
|
||||||
|
using EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.ListSources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List data source for the search dialog with filtering and colored rendering.
|
||||||
|
/// </summary>
|
||||||
|
public class SearchListSource(List<SearchResult> items) : IListDataSource
|
||||||
|
{
|
||||||
|
private readonly List<SearchResult> _allItems = items;
|
||||||
|
private List<SearchResult> _filtered = [.. items];
|
||||||
|
|
||||||
|
private static readonly Attribute ChannelAttribute = new(Color.BrightCyan, Color.None);
|
||||||
|
private static readonly Attribute ActionAttribute = new(Color.White, Color.None);
|
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
|
public int Count => _filtered.Count;
|
||||||
|
public int MaxItemLength => _filtered.Count > 0 ? _filtered.Max(i => i.Label.GetColumns()) : 0;
|
||||||
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
|
public void Filter(string query)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
_filtered = [.. _allItems];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_filtered = [.. _allItems.Where(i =>
|
||||||
|
i.Label.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| i.Key.Contains(query, StringComparison.OrdinalIgnoreCase))];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SuspendCollectionChangedEvent)
|
||||||
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SearchResult? GetItem(int index) => index >= 0 && index < _filtered.Count ? _filtered[index] : null;
|
||||||
|
|
||||||
|
public bool IsMarked(int item) => false;
|
||||||
|
public void SetMark(int item, bool value) { }
|
||||||
|
public IList ToList() => _filtered.Select(i => (object)i.Label).ToList();
|
||||||
|
|
||||||
|
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||||
|
{
|
||||||
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
|
var entry = _filtered[item];
|
||||||
|
var fillAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
||||||
|
|
||||||
|
Attribute itemAttr;
|
||||||
|
if (selected)
|
||||||
|
{
|
||||||
|
itemAttr = fillAttr;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var raw = entry.Type switch
|
||||||
|
{
|
||||||
|
SearchResultType.Channel => ChannelAttribute,
|
||||||
|
SearchResultType.Action => ActionAttribute,
|
||||||
|
_ => fillAttr
|
||||||
|
};
|
||||||
|
itemAttr = raw.Background == Color.None ? raw with { Background = fillAttr.Background } : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
listView.SetAttribute(itemAttr);
|
||||||
|
|
||||||
|
var drawn = RenderHelpers.WriteText(listView, entry.Label, 0, width);
|
||||||
|
|
||||||
|
listView.SetAttribute(fillAttr);
|
||||||
|
for (var i = drawn; i < width; i++)
|
||||||
|
listView.AddStr(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Client.Services;
|
using EchoHub.Client.Services;
|
||||||
using EchoHub.Client.Themes;
|
using EchoHub.Client.Themes;
|
||||||
@@ -6,6 +7,7 @@ using EchoHub.Client.UI.Helpers;
|
|||||||
using EchoHub.Client.UI.ListSources;
|
using EchoHub.Client.UI.ListSources;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
using Serilog;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
using Terminal.Gui.Configuration;
|
using Terminal.Gui.Configuration;
|
||||||
using Terminal.Gui.Drawing;
|
using Terminal.Gui.Drawing;
|
||||||
@@ -38,7 +40,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
private readonly UserListSource _usersListSource;
|
private readonly UserListSource _usersListSource;
|
||||||
private bool _usersPanelVisible = true;
|
private bool _usersPanelVisible = true;
|
||||||
private const int UsersPanelWidth = 22;
|
private const int UsersPanelWidth = 22;
|
||||||
|
private const string DefaultInputTitle = "Message │ Enter=send │ Tab=complete │ Ctrl+K=search │ F6=pick message";
|
||||||
private static readonly Key F2Key = Key.F2;
|
private static readonly Key F2Key = Key.F2;
|
||||||
|
private bool _hasStagedAttachments;
|
||||||
|
|
||||||
internal static readonly string AppVersion =
|
internal static readonly string AppVersion =
|
||||||
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
|
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
|
||||||
@@ -48,12 +52,18 @@ public sealed partial class MainWindow : Runnable
|
|||||||
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
||||||
private static readonly Key AltQKey = Key.Q.WithAlt;
|
private static readonly Key AltQKey = Key.Q.WithAlt;
|
||||||
private static readonly Key TabKey = Key.Tab;
|
private static readonly Key TabKey = Key.Tab;
|
||||||
|
private static readonly Key CtrlKKey = Key.K.WithCtrl;
|
||||||
|
private static readonly Key CtrlVKey = Key.V.WithCtrl;
|
||||||
|
private static readonly Key CtrlXKey = Key.X.WithCtrl;
|
||||||
|
private static readonly Key CtrlCKey = Key.C.WithCtrl;
|
||||||
|
private static readonly Key CtrlYKey = Key.Y.WithCtrl;
|
||||||
|
private static readonly Key F6Key = Key.F6;
|
||||||
|
|
||||||
// Available slash commands for Tab autocomplete
|
// Available slash commands for Tab autocomplete
|
||||||
private static readonly string[] SlashCommands =
|
private static readonly string[] SlashCommands =
|
||||||
[
|
[
|
||||||
"/status", "/nick", "/color", "/theme", "/send",
|
"/status", "/nick", "/color", "/theme", "/send",
|
||||||
"/avatar", "/profile", "/servers", "/join", "/leave",
|
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/size", "/downloadpath",
|
||||||
"/topic", "/users", "/kick", "/ban", "/unban",
|
"/topic", "/users", "/kick", "/ban", "/unban",
|
||||||
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
|
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
|
||||||
];
|
];
|
||||||
@@ -61,6 +71,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
private readonly Dictionary<string, string?> _channelTopics = [];
|
private readonly Dictionary<string, string?> _channelTopics = [];
|
||||||
private readonly Dictionary<string, bool> _channelPublic = [];
|
private readonly Dictionary<string, bool> _channelPublic = [];
|
||||||
|
private readonly HashSet<string> _channelProtected = [];
|
||||||
private readonly ChannelListSource _channelListSource;
|
private readonly ChannelListSource _channelListSource;
|
||||||
private readonly ChatMessageManager _messageManager;
|
private readonly ChatMessageManager _messageManager;
|
||||||
private string _connectionStatus = "Disconnected";
|
private string _connectionStatus = "Disconnected";
|
||||||
@@ -116,6 +127,11 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? OnSavedServersRequested;
|
public event Action? OnSavedServersRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user scrolls to the top of the message list and older messages should be loaded.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnLoadMoreRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired when the user requests to create a new channel.
|
/// Fired when the user requests to create a new channel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -141,6 +157,16 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string, string>? OnFileDownloadRequested;
|
public event Action<string, string>? OnFileDownloadRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user activates an image's "[save original]" line. Parameters: attachmentUrl, fileName.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, string>? OnImageSaveRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<Guid>? OnDeleteMessageRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
|
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -151,11 +177,17 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string>? OnChannelJoinRequested;
|
public event Action<string>? OnChannelJoinRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to open the search dialog (via menu or Ctrl+K).
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnSearchRequested;
|
||||||
|
|
||||||
public MainWindow(IApplication app, ChatMessageManager messageManager)
|
public MainWindow(IApplication app, ChatMessageManager messageManager)
|
||||||
{
|
{
|
||||||
_app = app;
|
_app = app;
|
||||||
_messageManager = messageManager;
|
_messageManager = messageManager;
|
||||||
_messageManager.MessagesChanged += OnMessagesChanged;
|
_messageManager.MessagesChanged += OnMessagesChanged;
|
||||||
|
_messageManager.HistoryPrepended += OnHistoryPrepended;
|
||||||
Arrangement = ViewArrangement.Fixed;
|
Arrangement = ViewArrangement.Fixed;
|
||||||
|
|
||||||
// Menu bar at the top
|
// Menu bar at the top
|
||||||
@@ -216,13 +248,18 @@ public sealed partial class MainWindow : Runnable
|
|||||||
};
|
};
|
||||||
_messageList.Source = new ChatListSource();
|
_messageList.Source = new ChatListSource();
|
||||||
_messageList.Accepting += OnMessageListAccepting;
|
_messageList.Accepting += OnMessageListAccepting;
|
||||||
|
_messageList.KeyDown += OnMessageListKeyDown;
|
||||||
|
_messageList.MouseEvent += OnMessageListMouseEvent;
|
||||||
|
_messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled;
|
||||||
|
_messageList.VerticalScrollBar.Visible = true;
|
||||||
|
|
||||||
_chatFrame.Add(_messageList);
|
_chatFrame.Add(_messageList);
|
||||||
Add(_chatFrame);
|
Add(_chatFrame);
|
||||||
|
|
||||||
// Bottom input area
|
// Bottom input area
|
||||||
_inputFrame = new FrameView
|
_inputFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete",
|
Title = DefaultInputTitle,
|
||||||
X = 22,
|
X = 22,
|
||||||
Y = Pos.Bottom(_chatFrame),
|
Y = Pos.Bottom(_chatFrame),
|
||||||
Width = Dim.Fill(UsersPanelWidth),
|
Width = Dim.Fill(UsersPanelWidth),
|
||||||
@@ -237,6 +274,10 @@ public sealed partial class MainWindow : Runnable
|
|||||||
Height = Dim.Fill(),
|
Height = Dim.Fill(),
|
||||||
WordWrap = true
|
WordWrap = true
|
||||||
};
|
};
|
||||||
|
// Terminal.Gui binds Ctrl+W to Command.Cut, whose OS clipboard write can throw
|
||||||
|
// Win32Exception when another process holds the clipboard, crashing the app.
|
||||||
|
// Rebind it to delete-word-backward (readline behavior), which never touches the clipboard.
|
||||||
|
_inputField.KeyBindings.ReplaceCommands(Key.W.WithCtrl, Command.KillWordLeft);
|
||||||
_inputField.KeyDown += OnInputKeyDown;
|
_inputField.KeyDown += OnInputKeyDown;
|
||||||
_inputField.ContentsChanged += OnInputContentsChanged;
|
_inputField.ContentsChanged += OnInputContentsChanged;
|
||||||
_inputFrame.Add(_inputField);
|
_inputFrame.Add(_inputField);
|
||||||
@@ -290,6 +331,27 @@ public sealed partial class MainWindow : Runnable
|
|||||||
KeyDown += OnWindowKeyDown;
|
KeyDown += OnWindowKeyDown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the attachment staging indicator shown on the input frame's title, including the
|
||||||
|
/// current ASCII-art size for images. Passing an empty list restores the default hint.
|
||||||
|
/// </summary>
|
||||||
|
public void SetStagedAttachments(IReadOnlyList<string> fileNames, string asciiSizeLabel)
|
||||||
|
{
|
||||||
|
_hasStagedAttachments = fileNames.Count > 0;
|
||||||
|
if (fileNames.Count == 0)
|
||||||
|
{
|
||||||
|
_inputFrame.Title = DefaultInputTitle;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var names = string.Join(", ", fileNames);
|
||||||
|
if (names.Length > 45)
|
||||||
|
names = names[..42] + "...";
|
||||||
|
_inputFrame.Title = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear";
|
||||||
|
}
|
||||||
|
_inputFrame.SetNeedsDraw();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies the currently registered color schemes to all views.
|
/// Applies the currently registered color schemes to all views.
|
||||||
/// Call after theme changes to refresh colors.
|
/// Call after theme changes to refresh colors.
|
||||||
@@ -427,19 +489,26 @@ public sealed partial class MainWindow : Runnable
|
|||||||
// Audio/file attachments take priority
|
// Audio/file attachments take priority
|
||||||
if (line.AttachmentUrl is not null && line.AttachmentFileName is not null)
|
if (line.AttachmentUrl is not null && line.AttachmentFileName is not null)
|
||||||
{
|
{
|
||||||
if (line.Type == MessageType.Audio)
|
if (line.AttachmentKind == AttachmentKind.Audio)
|
||||||
{
|
{
|
||||||
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line.Type == MessageType.File)
|
if (line.AttachmentKind == AttachmentKind.File)
|
||||||
{
|
{
|
||||||
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (line.AttachmentKind == AttachmentKind.Image)
|
||||||
|
{
|
||||||
|
OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var lineText = line.ToString();
|
var lineText = line.ToString();
|
||||||
@@ -472,6 +541,139 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnMessageListKeyDown(object? sender, Key e)
|
||||||
|
{
|
||||||
|
// F6 returns focus to the input box.
|
||||||
|
if (e.KeyCode == F6Key.KeyCode)
|
||||||
|
{
|
||||||
|
_inputField.SetFocus();
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.KeyCode != Key.Delete.KeyCode && e.KeyCode != Key.Backspace.KeyCode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_messageList.Source is not ChatListSource source)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var index = _messageList.SelectedItem;
|
||||||
|
if (!index.HasValue || index.Value < 0 || index.Value >= source.Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var line = source.GetLine(index.Value);
|
||||||
|
if (line?.MessageId is not { } messageId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Server enforces the real permission (own message, or Mod+ over a lower role);
|
||||||
|
// the client just confirms intent and lets the server reject if disallowed.
|
||||||
|
ConfirmDeleteMessage(messageId);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMessageListMouseEvent(object? sender, Mouse e)
|
||||||
|
{
|
||||||
|
if (!e.Flags.HasFlag(MouseFlags.RightButtonClicked))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var index = _messageList.TopItem + pos.Y;
|
||||||
|
if (index < 0 || index >= source.Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Select the right-clicked row (so the menu acts on it and it highlights), then show the menu.
|
||||||
|
_messageList.SelectedItem = index;
|
||||||
|
_messageList.SetFocus();
|
||||||
|
|
||||||
|
var line = source.GetLine(index);
|
||||||
|
if (line is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ShowMessageContextMenu(line, e.ScreenPosition);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds and shows a right-click context menu for a message line: attachment actions,
|
||||||
|
/// mention/profile for the sender, copy, and delete (permission enforced server-side).
|
||||||
|
/// </summary>
|
||||||
|
private void ShowMessageContextMenu(ChatLine line, System.Drawing.Point screenPosition)
|
||||||
|
{
|
||||||
|
var items = new List<View>();
|
||||||
|
var sender = line.SenderUsername;
|
||||||
|
|
||||||
|
if (line.AttachmentKind is { } kind && line.AttachmentUrl is { } url && line.AttachmentFileName is { } name)
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case AttachmentKind.Image:
|
||||||
|
items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty));
|
||||||
|
break;
|
||||||
|
case AttachmentKind.Audio:
|
||||||
|
items.Add(new MenuItem("Play audio", "", () => OnAudioPlayRequested?.Invoke(url, name), Key.Empty));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
items.Add(new MenuItem("Download file", "", () => OnFileDownloadRequested?.Invoke(url, name), Key.Empty));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sender is not null)
|
||||||
|
{
|
||||||
|
items.Add(new MenuItem($"Mention @{sender}", "", () => MentionUser(sender), Key.Empty));
|
||||||
|
items.Add(new MenuItem($"View {sender}'s profile", "", () => OnUserProfileRequested?.Invoke(sender), Key.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(new MenuItem("Copy text", "", () => CopyToClipboard(line.ToString()), Key.Empty));
|
||||||
|
|
||||||
|
if (line.MessageId is { } messageId)
|
||||||
|
{
|
||||||
|
items.Add(new MenuItem("Copy message ID", "", () => CopyToClipboard(messageId.ToString()), Key.Empty));
|
||||||
|
items.Add(new Line());
|
||||||
|
items.Add(new MenuItem("Delete message", "", () => ConfirmDeleteMessage(messageId), Key.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var menu = new PopoverMenu(items);
|
||||||
|
_app.Popovers?.Register(menu);
|
||||||
|
menu.MakeVisible(screenPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MentionUser(string username)
|
||||||
|
{
|
||||||
|
_inputField.InsertText($"@{username} ");
|
||||||
|
_inputField.SetFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CopyToClipboard(string text)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_app.Clipboard?.TrySetClipboardData(text);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Copy to clipboard failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfirmDeleteMessage(Guid messageId)
|
||||||
|
{
|
||||||
|
var confirm = MessageBox.Query(_app, "Delete Message", "Delete this message?", "Delete", "Cancel");
|
||||||
|
if (confirm == 0)
|
||||||
|
OnDeleteMessageRequested?.Invoke(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> e)
|
||||||
|
{
|
||||||
|
if (_messageList.VerticalScrollBar.Value == 0)
|
||||||
|
OnLoadMoreRequested?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnUsersListAccepting(object? sender, CommandEventArgs e)
|
private void OnUsersListAccepting(object? sender, CommandEventArgs e)
|
||||||
{
|
{
|
||||||
var index = _usersList.SelectedItem;
|
var index = _usersList.SelectedItem;
|
||||||
@@ -501,7 +703,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
else if (e.KeyCode == EnterKey.KeyCode)
|
else if (e.KeyCode == EnterKey.KeyCode)
|
||||||
{
|
{
|
||||||
var text = _inputField.Text?.Trim() ?? string.Empty;
|
var text = _inputField.Text?.Trim() ?? string.Empty;
|
||||||
if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
// Send when there's text, or when only attachments are staged (empty caption).
|
||||||
|
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
|
||||||
|
&& !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||||
{
|
{
|
||||||
OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
|
OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
|
||||||
_inputField.Text = string.Empty;
|
_inputField.Text = string.Empty;
|
||||||
@@ -513,6 +717,56 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_app.RequestStop();
|
_app.RequestStop();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
else if (e.KeyCode == CtrlKKey.KeyCode)
|
||||||
|
{
|
||||||
|
ShowSearchDialog();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.KeyCode == F6Key.KeyCode)
|
||||||
|
{
|
||||||
|
// Move focus into the message list so you can select a message (arrows) and
|
||||||
|
// delete it (Delete). F6 again returns focus here. (Esc is the app quit key.)
|
||||||
|
FocusMessageList();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode)
|
||||||
|
{
|
||||||
|
// If a file was copied in the OS file manager, the clipboard holds a file list
|
||||||
|
// (not text) — attach it. Otherwise paste text. This is the reliable path on
|
||||||
|
// Windows Terminal, which never pastes copied files as text.
|
||||||
|
if (ClipboardFiles.TryGetFiles(out var pastedFiles))
|
||||||
|
StageFiles(pastedFiles);
|
||||||
|
else
|
||||||
|
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.KeyCode == CtrlXKey.KeyCode)
|
||||||
|
{
|
||||||
|
GuardedClipboardAction(() => _inputField.Cut(), "cut");
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.KeyCode == CtrlCKey.KeyCode)
|
||||||
|
{
|
||||||
|
GuardedClipboardAction(() => _inputField.Copy(), "copy");
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a clipboard-backed edit action, swallowing transient OS clipboard failures
|
||||||
|
/// (e.g. another process holding the Windows clipboard) that would otherwise
|
||||||
|
/// propagate out of the input loop and crash the app.
|
||||||
|
/// </summary>
|
||||||
|
private static void GuardedClipboardAction(Action action, string operation)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Clipboard {Operation} failed", operation);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool _suppressEmojiReplace;
|
private bool _suppressEmojiReplace;
|
||||||
@@ -526,6 +780,27 @@ public sealed partial class MainWindow : Runnable
|
|||||||
if (string.IsNullOrEmpty(text))
|
if (string.IsNullOrEmpty(text))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// A file dropped onto the terminal is delivered as its absolute path inserted into the
|
||||||
|
// input — often character by character (this Terminal.Gui build has no bracketed-paste
|
||||||
|
// coalescing). As soon as the input resolves to existing file path(s), route them
|
||||||
|
// through /send (which stages them) instead of leaving a raw path to be sent as a message.
|
||||||
|
if (DroppedFileParser.LooksLikePath(text) && DroppedFileParser.TryGetFiles(text, out var droppedFiles)
|
||||||
|
&& !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||||
|
{
|
||||||
|
_suppressEmojiReplace = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_inputField.Text = string.Empty;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressEmojiReplace = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
StageFiles(droppedFiles);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var replaced = EmojiHelper.ReplaceEmoji(text);
|
var replaced = EmojiHelper.ReplaceEmoji(text);
|
||||||
if (replaced == text)
|
if (replaced == text)
|
||||||
return;
|
return;
|
||||||
@@ -535,9 +810,15 @@ public sealed partial class MainWindow : Runnable
|
|||||||
var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta);
|
var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta);
|
||||||
|
|
||||||
_suppressEmojiReplace = true;
|
_suppressEmojiReplace = true;
|
||||||
_inputField.Text = replaced;
|
try
|
||||||
_inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow);
|
{
|
||||||
_suppressEmojiReplace = false;
|
_inputField.Text = replaced;
|
||||||
|
_inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressEmojiReplace = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -572,6 +853,23 @@ public sealed partial class MainWindow : Runnable
|
|||||||
if (prefix.Length > text.Length)
|
if (prefix.Length > text.Length)
|
||||||
_inputField.Text = prefix;
|
_inputField.Text = prefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move cursor to end after autocomplete
|
||||||
|
_inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes files (from a drop or a file-clipboard paste) through the /send pipeline, which
|
||||||
|
/// stages them; the next Enter sends them with any typed caption.
|
||||||
|
/// </summary>
|
||||||
|
private void StageFiles(IEnumerable<string> files)
|
||||||
|
{
|
||||||
|
var channel = _messageManager.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnChatViewportChanged()
|
private void OnChatViewportChanged()
|
||||||
@@ -597,6 +895,16 @@ public sealed partial class MainWindow : Runnable
|
|||||||
ToggleUsersPanel();
|
ToggleUsersPanel();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
else if (e.KeyCode == CtrlKKey.KeyCode)
|
||||||
|
{
|
||||||
|
ShowSearchDialog();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowSearchDialog()
|
||||||
|
{
|
||||||
|
OnSearchRequested?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnMessagesChanged(string channelName)
|
private void OnMessagesChanged(string channelName)
|
||||||
@@ -607,6 +915,26 @@ public sealed partial class MainWindow : Runnable
|
|||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnHistoryPrepended(string channelName)
|
||||||
|
{
|
||||||
|
if (channelName != _messageManager.CurrentChannel)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var messages = _messageManager.GetMessages(channelName);
|
||||||
|
if (messages is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var oldCount = (_messageList.Source as ChatListSource)?.Count ?? 0;
|
||||||
|
|
||||||
|
RefreshMessages();
|
||||||
|
|
||||||
|
// Scroll to the item that was at the top before the prepend so the user
|
||||||
|
// stays at their previous reading position rather than jumping to the top.
|
||||||
|
var prependedCount = (_messageList.Source as ChatListSource)?.Count - oldCount;
|
||||||
|
if (prependedCount > 0)
|
||||||
|
_messageList.SelectedItem = prependedCount;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Set the list of available channels, storing topics, and refresh the channel list view.
|
/// Set the list of available channels, storing topics, and refresh the channel list view.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -615,11 +943,14 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_channelNames.Clear();
|
_channelNames.Clear();
|
||||||
_channelTopics.Clear();
|
_channelTopics.Clear();
|
||||||
_channelPublic.Clear();
|
_channelPublic.Clear();
|
||||||
|
_channelProtected.Clear();
|
||||||
foreach (var ch in channels)
|
foreach (var ch in channels)
|
||||||
{
|
{
|
||||||
_channelNames.Add(ch.Name);
|
_channelNames.Add(ch.Name);
|
||||||
_channelTopics[ch.Name] = ch.Topic;
|
_channelTopics[ch.Name] = ch.Topic;
|
||||||
_channelPublic[ch.Name] = ch.IsPublic;
|
_channelPublic[ch.Name] = ch.IsPublic;
|
||||||
|
if (ch.IsProtected)
|
||||||
|
_channelProtected.Add(ch.Name);
|
||||||
}
|
}
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
}
|
||||||
@@ -627,13 +958,23 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
|
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void EnsureChannelInList(string channelName, bool? isPublic = null)
|
public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null)
|
||||||
{
|
{
|
||||||
if (isPublic.HasValue)
|
if (isPublic.HasValue)
|
||||||
_channelPublic[channelName] = isPublic.Value;
|
_channelPublic[channelName] = isPublic.Value;
|
||||||
|
|
||||||
|
if (isProtected.HasValue)
|
||||||
|
{
|
||||||
|
if (isProtected.Value) _channelProtected.Add(channelName);
|
||||||
|
else _channelProtected.Remove(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
if (_channelNames.Contains(channelName))
|
if (_channelNames.Contains(channelName))
|
||||||
|
{
|
||||||
|
if (isProtected.HasValue)
|
||||||
|
RefreshChannelList();
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_channelNames.Add(channelName);
|
_channelNames.Add(channelName);
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
@@ -647,6 +988,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_channelNames.Remove(channelName);
|
_channelNames.Remove(channelName);
|
||||||
_channelTopics.Remove(channelName);
|
_channelTopics.Remove(channelName);
|
||||||
_channelPublic.Remove(channelName);
|
_channelPublic.Remove(channelName);
|
||||||
|
_channelProtected.Remove(channelName);
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,6 +1074,8 @@ public sealed partial class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
_channelPublic.TryGetValue(currentChannel, out var isPublic);
|
_channelPublic.TryGetValue(currentChannel, out var isPublic);
|
||||||
var typeSuffix = isPublic ? "public" : "private";
|
var typeSuffix = isPublic ? "public" : "private";
|
||||||
|
if (_channelProtected.Contains(currentChannel))
|
||||||
|
typeSuffix += " +k";
|
||||||
Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr);
|
Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,6 +1139,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_messageManager.ClearAll();
|
_messageManager.ClearAll();
|
||||||
_channelTopics.Clear();
|
_channelTopics.Clear();
|
||||||
_channelPublic.Clear();
|
_channelPublic.Clear();
|
||||||
|
_channelProtected.Clear();
|
||||||
_channelListSource.Update([], [], string.Empty);
|
_channelListSource.Update([], [], string.Empty);
|
||||||
_channelList.Source = _channelListSource;
|
_channelList.Source = _channelListSource;
|
||||||
_chatFrame.Title = "Chat";
|
_chatFrame.Title = "Chat";
|
||||||
@@ -814,6 +1159,26 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_inputField.SetFocus();
|
_inputField.SetFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves focus into the message list for selection (arrows) and deletion (Delete). Selects the
|
||||||
|
/// most recent message when nothing is selected. No-op when the channel has no messages.
|
||||||
|
/// </summary>
|
||||||
|
private void FocusMessageList()
|
||||||
|
{
|
||||||
|
if (_messageList.Source is not ChatListSource source || source.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_messageList.SelectedItem.HasValue
|
||||||
|
|| _messageList.SelectedItem < 0
|
||||||
|
|| _messageList.SelectedItem >= source.Count)
|
||||||
|
{
|
||||||
|
_messageList.SelectedItem = source.Count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
_messageList.SetFocus();
|
||||||
|
_messageList.SetNeedsDraw();
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshMessages()
|
private void RefreshMessages()
|
||||||
{
|
{
|
||||||
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
||||||
@@ -855,7 +1220,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshChannelList()
|
private void RefreshChannelList()
|
||||||
{
|
{
|
||||||
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel);
|
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelProtected);
|
||||||
_channelList.Source = _channelListSource;
|
_channelList.Source = _channelListSource;
|
||||||
|
|
||||||
// Restore selection to current channel
|
// Restore selection to current channel
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public static class HubConstants
|
|||||||
public const int MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
|
public const int MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
|
||||||
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
||||||
public const int MaxMessageNewlines = 30;
|
public const int MaxMessageNewlines = 30;
|
||||||
|
public const int MaxAttachmentsPerMessage = 10;
|
||||||
public const int MaxConsecutiveNewlines = 1;
|
public const int MaxConsecutiveNewlines = 1;
|
||||||
public const int AsciiArtWidth = 80;
|
public const int AsciiArtWidth = 80;
|
||||||
public const int AsciiArtHeight = 40;
|
public const int AsciiArtHeight = 40;
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ public static partial class ValidationConstants
|
|||||||
public const string HexColorPattern = @"^#[0-9a-fA-F]{6}$";
|
public const string HexColorPattern = @"^#[0-9a-fA-F]{6}$";
|
||||||
|
|
||||||
public const int MaxPasswordLength = 128;
|
public const int MaxPasswordLength = 128;
|
||||||
|
public const int MinChannelPasswordLength = 3;
|
||||||
public const int MaxDisplayNameLength = 100;
|
public const int MaxDisplayNameLength = 100;
|
||||||
public const int MaxBioLength = 500;
|
public const int MaxBioLength = 500;
|
||||||
public const int MaxStatusMessageLength = 100;
|
public const int MaxStatusMessageLength = 100;
|
||||||
public const int MaxChannelTopicLength = 500;
|
public const int MaxChannelTopicLength = 500;
|
||||||
public const int MaxHistoryCount = 100;
|
public const int MaxHistoryCount = 200;
|
||||||
|
|
||||||
[GeneratedRegex(UsernamePattern)]
|
[GeneratedRegex(UsernamePattern)]
|
||||||
public static partial Regex UsernameRegex();
|
public static partial Regex UsernameRegex();
|
||||||
|
|||||||
@@ -6,17 +6,23 @@ public interface IChannelService
|
|||||||
{
|
{
|
||||||
// Channel CRUD
|
// Channel CRUD
|
||||||
Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit);
|
Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit);
|
||||||
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic);
|
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic,
|
||||||
|
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null);
|
||||||
Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic);
|
Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic);
|
||||||
|
Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password);
|
||||||
|
Task<ChannelOperationResult> RekeyChannelAsync(Guid callerUserId, string channelName,
|
||||||
|
string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey);
|
||||||
Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName);
|
Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName);
|
||||||
|
|
||||||
// Channel queries
|
// Channel queries
|
||||||
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
|
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
|
||||||
Task<List<ChannelListItem>> GetChannelListAsync();
|
Task<List<ChannelListItem>> GetChannelListAsync();
|
||||||
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
|
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
|
||||||
|
Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName);
|
||||||
|
Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName);
|
||||||
|
|
||||||
// Membership
|
// Membership
|
||||||
Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName);
|
Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
|
public record ChannelListItem(string Name, string? Topic, int OnlineCount, bool IsPublic = true, bool IsProtected = false);
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ public interface IChatService
|
|||||||
Task<string?> UserDisconnectedAsync(string connectionId);
|
Task<string?> UserDisconnectedAsync(string connectionId);
|
||||||
|
|
||||||
// Channel operations
|
// Channel operations
|
||||||
Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName);
|
Task<(List<MessageDto> History, string? Error, bool PasswordRequired)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName, string? password = null);
|
||||||
Task LeaveChannelAsync(string connectionId, string username, string channelName);
|
Task LeaveChannelAsync(string connectionId, string username, string channelName);
|
||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
|
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
|
||||||
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count);
|
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0);
|
||||||
|
|
||||||
// Presence
|
// Presence
|
||||||
Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage);
|
Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage);
|
||||||
|
|||||||
@@ -8,20 +8,31 @@ public record MessageDto(
|
|||||||
string SenderUsername,
|
string SenderUsername,
|
||||||
string? SenderNicknameColor,
|
string? SenderNicknameColor,
|
||||||
string ChannelName,
|
string ChannelName,
|
||||||
MessageType Type,
|
|
||||||
string? AttachmentUrl,
|
|
||||||
string? AttachmentFileName,
|
|
||||||
DateTimeOffset SentAt,
|
DateTimeOffset SentAt,
|
||||||
long? AttachmentFileSize = null,
|
List<AttachmentDto>? Attachments = null,
|
||||||
List<EmbedDto>? Embeds = null);
|
List<EmbedDto>? Embeds = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A file attached to a message. <see cref="AsciiPreview"/> holds the color-tag art for
|
||||||
|
/// images (null otherwise). For end-to-end encrypted channels the content behind
|
||||||
|
/// <see cref="Url"/> and the preview are ciphertext the server cannot read.
|
||||||
|
/// </summary>
|
||||||
|
public record AttachmentDto(
|
||||||
|
AttachmentKind Kind,
|
||||||
|
string Url,
|
||||||
|
string FileName,
|
||||||
|
long FileSize,
|
||||||
|
string? AsciiPreview = null);
|
||||||
|
|
||||||
public record ChannelDto(
|
public record ChannelDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
string? Topic,
|
string? Topic,
|
||||||
bool IsPublic,
|
bool IsPublic,
|
||||||
int MessageCount,
|
int MessageCount,
|
||||||
DateTimeOffset CreatedAt);
|
DateTimeOffset CreatedAt,
|
||||||
|
bool IsProtected = false,
|
||||||
|
bool IsEncrypted = false);
|
||||||
|
|
||||||
public record UserDto(
|
public record UserDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -33,13 +44,41 @@ public record UserDto(
|
|||||||
|
|
||||||
public record SendMessageRequest(string ChannelName, string Content);
|
public record SendMessageRequest(string ChannelName, string Content);
|
||||||
|
|
||||||
public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true);
|
public record CreateChannelRequest(
|
||||||
|
string Name,
|
||||||
|
string? Topic = null,
|
||||||
|
bool IsPublic = true,
|
||||||
|
string? Password = null,
|
||||||
|
string? EncryptionSalt = null,
|
||||||
|
string? WrappedRoomKey = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public crypto metadata for a channel — enough for a client to derive its join
|
||||||
|
/// credential from a passphrase. Never includes the wrapped room key.
|
||||||
|
/// </summary>
|
||||||
|
public record ChannelCryptoDto(bool IsEncrypted, string? EncryptionSalt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Passphrase change for an encrypted channel: the client proves knowledge of the old
|
||||||
|
/// passphrase (old auth key), then supplies the re-wrapped room key under the new one.
|
||||||
|
/// </summary>
|
||||||
|
public record RekeyChannelRequest(
|
||||||
|
string OldPassword,
|
||||||
|
string NewPassword,
|
||||||
|
string NewEncryptionSalt,
|
||||||
|
string NewWrappedRoomKey);
|
||||||
|
|
||||||
public record UpdateTopicRequest(string? Topic);
|
public record UpdateTopicRequest(string? Topic);
|
||||||
|
|
||||||
public record SendUrlRequest(string Url);
|
public record SendUrlRequest(string Url);
|
||||||
|
|
||||||
public record JoinChannelResult(bool Success, List<MessageDto> History, string? Error = null);
|
public record JoinChannelResult(
|
||||||
|
bool Success,
|
||||||
|
List<MessageDto> History,
|
||||||
|
string? Error = null,
|
||||||
|
bool PasswordRequired = false,
|
||||||
|
string? EncryptionSalt = null,
|
||||||
|
string? WrappedRoomKey = null);
|
||||||
|
|
||||||
public record EmbedDto(
|
public record EmbedDto(
|
||||||
string? SiteName,
|
string? SiteName,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
@@ -6,4 +6,8 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A file attached to a message (image, audio, or any file). A message may carry
|
||||||
|
/// zero or more attachments alongside its text content (Discord-style).
|
||||||
|
/// </summary>
|
||||||
|
public class Attachment
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid MessageId { get; set; }
|
||||||
|
public Message? Message { get; set; }
|
||||||
|
|
||||||
|
public AttachmentKind Kind { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Relative download URL, e.g. <c>/api/files/{fileId}</c>.</summary>
|
||||||
|
public required string Url { get; set; }
|
||||||
|
public required string FileName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Stored blob size in bytes (ciphertext size for encrypted channels).</summary>
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rendered ASCII-art preview for images (color-tag format). Null for audio/files.
|
||||||
|
/// Stored encrypted-at-rest when database encryption is enabled, and room-encrypted
|
||||||
|
/// for end-to-end encrypted channels.
|
||||||
|
/// </summary>
|
||||||
|
public string? AsciiPreview { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The kind of a message attachment. Determines how the client renders it
|
||||||
|
/// (ASCII preview for images, a play affordance for audio, a download line for files).
|
||||||
|
/// </summary>
|
||||||
|
public enum AttachmentKind
|
||||||
|
{
|
||||||
|
Image,
|
||||||
|
Audio,
|
||||||
|
File
|
||||||
|
}
|
||||||
@@ -6,6 +6,13 @@ public class Channel
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string? Topic { get; set; }
|
public string? Topic { get; set; }
|
||||||
public bool IsPublic { get; set; } = true;
|
public bool IsPublic { get; set; } = true;
|
||||||
|
public string? PasswordHash { get; set; }
|
||||||
|
|
||||||
|
// End-to-end encryption envelope (client-generated; server cannot decrypt room content).
|
||||||
|
// EncryptionSalt: PBKDF2 salt for passphrase-derived keys. WrappedRoomKey: the room
|
||||||
|
// content key encrypted under the passphrase-derived key-encryption key.
|
||||||
|
public string? EncryptionSalt { get; set; }
|
||||||
|
public string? WrappedRoomKey { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public Guid CreatedByUserId { get; set; }
|
public Guid CreatedByUserId { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ namespace EchoHub.Core.Models;
|
|||||||
public class Message
|
public class Message
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The message text/caption. May be empty when the message only carries attachments.</summary>
|
||||||
public required string Content { get; set; }
|
public required string Content { get; set; }
|
||||||
public MessageType Type { get; set; } = MessageType.Text;
|
|
||||||
public string? AttachmentUrl { get; set; }
|
|
||||||
public string? AttachmentFileName { get; set; }
|
|
||||||
public long? AttachmentFileSize { get; set; }
|
|
||||||
public string? EmbedJson { get; set; }
|
public string? EmbedJson { get; set; }
|
||||||
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
@@ -16,4 +15,16 @@ public class Message
|
|||||||
|
|
||||||
public Guid SenderUserId { get; set; }
|
public Guid SenderUserId { get; set; }
|
||||||
public required string SenderUsername { get; set; }
|
public required string SenderUsername { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Files attached to this message. Empty for a plain text message.</summary>
|
||||||
|
public List<Attachment> Attachments { get; set; } = [];
|
||||||
|
|
||||||
|
// ── Legacy columns (pre-attachments model) ──────────────────────────────
|
||||||
|
// Retained so the one-time startup data migration can fold old single-attachment
|
||||||
|
// messages into Attachments. New code never writes these; they are nulled out
|
||||||
|
// once migrated. Not exposed in DTOs. See DataMigrationService.MigrateLegacyAttachmentsAsync.
|
||||||
|
public MessageType Type { get; set; } = MessageType.Text;
|
||||||
|
public string? AttachmentUrl { get; set; }
|
||||||
|
public string? AttachmentFileName { get; set; }
|
||||||
|
public long? AttachmentFileSize { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EchoHub.Core.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side envelope encryption for private (end-to-end encrypted) channels.
|
||||||
|
///
|
||||||
|
/// Design: at creation the client generates a random 256-bit room content key (RCK)
|
||||||
|
/// that encrypts all room content. The RCK is stored on the server *wrapped*
|
||||||
|
/// (AES-GCM encrypted) by a key derived from the passphrase, next to a BCrypt hash
|
||||||
|
/// of a separately derived auth key used as the join gate. The passphrase, the
|
||||||
|
/// key-encryption key, and the RCK never leave the client, so the server can gate
|
||||||
|
/// joins and count/measure content without being able to read it. Changing the
|
||||||
|
/// passphrase only re-wraps the RCK — history is never re-encrypted.
|
||||||
|
///
|
||||||
|
/// Derivation: PBKDF2-SHA256(passphrase, salt, 210000 iterations) → 64 bytes;
|
||||||
|
/// first 32 bytes are the auth key (sent to the server as lowercase hex),
|
||||||
|
/// last 32 bytes are the key-encryption key (never sent).
|
||||||
|
/// </summary>
|
||||||
|
public static class RoomCrypto
|
||||||
|
{
|
||||||
|
public const string CiphertextPrefix = "$RC1$";
|
||||||
|
|
||||||
|
private const int Pbkdf2Iterations = 210_000;
|
||||||
|
private const int SaltSizeBytes = 16;
|
||||||
|
private const int KeySizeBytes = 32;
|
||||||
|
private const int NonceSizeBytes = 12;
|
||||||
|
private const int TagSizeBytes = 16;
|
||||||
|
|
||||||
|
public sealed record DerivedKeys(string AuthKeyHex, byte[] KeyEncryptionKey);
|
||||||
|
|
||||||
|
public static byte[] GenerateSalt() => RandomNumberGenerator.GetBytes(SaltSizeBytes);
|
||||||
|
|
||||||
|
public static byte[] GenerateRoomKey() => RandomNumberGenerator.GetBytes(KeySizeBytes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the auth key (join gate credential) and key-encryption key from a passphrase.
|
||||||
|
/// </summary>
|
||||||
|
public static DerivedKeys DeriveKeys(string passphrase, byte[] salt)
|
||||||
|
{
|
||||||
|
var okm = Rfc2898DeriveBytes.Pbkdf2(
|
||||||
|
Encoding.UTF8.GetBytes(passphrase), salt, Pbkdf2Iterations,
|
||||||
|
HashAlgorithmName.SHA256, KeySizeBytes * 2);
|
||||||
|
|
||||||
|
var authKey = Convert.ToHexString(okm.AsSpan(0, KeySizeBytes)).ToLowerInvariant();
|
||||||
|
var kek = okm[KeySizeBytes..];
|
||||||
|
CryptographicOperations.ZeroMemory(okm.AsSpan(0, KeySizeBytes));
|
||||||
|
return new DerivedKeys(authKey, kek);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encrypts UTF-8 text with the room key. Output: $RC1$base64(nonce||tag||ciphertext).</summary>
|
||||||
|
public static string EncryptText(string plaintext, byte[] key) =>
|
||||||
|
CiphertextPrefix + Convert.ToBase64String(EncryptBytes(Encoding.UTF8.GetBytes(plaintext), key));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decrypts text produced by <see cref="EncryptText"/>. Returns false when the input
|
||||||
|
/// is not room ciphertext or the key does not match.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryDecryptText(string content, byte[] key, out string plaintext)
|
||||||
|
{
|
||||||
|
plaintext = string.Empty;
|
||||||
|
if (!IsRoomCiphertext(content))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var blob = Convert.FromBase64String(content[CiphertextPrefix.Length..]);
|
||||||
|
plaintext = Encoding.UTF8.GetString(DecryptBytes(blob, key));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsRoomCiphertext(string? content) =>
|
||||||
|
content is not null && content.StartsWith(CiphertextPrefix, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Encrypts a binary blob (file contents) with the room key: nonce||tag||ciphertext.</summary>
|
||||||
|
public static byte[] EncryptBytes(byte[] plaintext, byte[] key)
|
||||||
|
{
|
||||||
|
var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
|
||||||
|
var ciphertext = new byte[plaintext.Length];
|
||||||
|
var tag = new byte[TagSizeBytes];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(key, TagSizeBytes);
|
||||||
|
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||||
|
|
||||||
|
var blob = new byte[NonceSizeBytes + TagSizeBytes + ciphertext.Length];
|
||||||
|
nonce.CopyTo(blob, 0);
|
||||||
|
tag.CopyTo(blob, NonceSizeBytes);
|
||||||
|
ciphertext.CopyTo(blob, NonceSizeBytes + TagSizeBytes);
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decrypts a blob produced by <see cref="EncryptBytes"/>. Throws <see cref="CryptographicException"/> on key mismatch.</summary>
|
||||||
|
public static byte[] DecryptBytes(byte[] blob, byte[] key)
|
||||||
|
{
|
||||||
|
if (blob.Length < NonceSizeBytes + TagSizeBytes)
|
||||||
|
throw new CryptographicException("Ciphertext blob is too short.");
|
||||||
|
|
||||||
|
var nonce = blob.AsSpan(0, NonceSizeBytes);
|
||||||
|
var tag = blob.AsSpan(NonceSizeBytes, TagSizeBytes);
|
||||||
|
var ciphertext = blob.AsSpan(NonceSizeBytes + TagSizeBytes);
|
||||||
|
var plaintext = new byte[ciphertext.Length];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(key, TagSizeBytes);
|
||||||
|
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wraps the room content key under the key-encryption key for server storage.</summary>
|
||||||
|
public static string WrapRoomKey(byte[] roomKey, byte[] kek) =>
|
||||||
|
Convert.ToBase64String(EncryptBytes(roomKey, kek));
|
||||||
|
|
||||||
|
/// <summary>Unwraps the stored room content key. Returns false when the KEK (passphrase) is wrong.</summary>
|
||||||
|
public static bool TryUnwrapRoomKey(string wrappedRoomKey, byte[] kek, out byte[] roomKey)
|
||||||
|
{
|
||||||
|
roomKey = [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
roomKey = DecryptBytes(Convert.FromBase64String(wrappedRoomKey), kek);
|
||||||
|
return roomKey.Length == KeySizeBytes;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace EchoHub.Server.Services;
|
namespace EchoHub.Core.Services;
|
||||||
|
|
||||||
public static class FileValidationHelper
|
public static class FileValidationHelper
|
||||||
{
|
{
|
||||||
+1
-1
@@ -4,7 +4,7 @@ using SixLabors.ImageSharp;
|
|||||||
using SixLabors.ImageSharp.PixelFormats;
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
using SixLabors.ImageSharp.Processing;
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
|
||||||
namespace EchoHub.Server.Services;
|
namespace EchoHub.Core.Services;
|
||||||
|
|
||||||
public class ImageToAsciiService
|
public class ImageToAsciiService
|
||||||
{
|
{
|
||||||
@@ -329,7 +329,7 @@ public sealed class IrcCommandHandler
|
|||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO,
|
||||||
$"{ServerName} EchoHub-IRC o o");
|
$"{ServerName} EchoHub-IRC o o");
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT,
|
||||||
"CHANTYPES=# NICKLEN=50 CHANNELLEN=100 :are supported by this server");
|
"CHANTYPES=# CHANMODES=b,k,, NICKLEN=50 CHANNELLEN=100 :are supported by this server");
|
||||||
|
|
||||||
await SendMotdAsync();
|
await SendMotdAsync();
|
||||||
}
|
}
|
||||||
@@ -371,8 +371,17 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
|
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
foreach (var rawChannel in channels)
|
// RFC 1459: optional second parameter carries comma-separated channel keys,
|
||||||
|
// paired with channels by position (JOIN #a,#b key1,key2).
|
||||||
|
var keys = msg.Parameters.Count > 1
|
||||||
|
? msg.Parameters[1].Split(',')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (var i = 0; i < channels.Length; i++)
|
||||||
{
|
{
|
||||||
|
var rawChannel = channels[i];
|
||||||
|
var key = i < keys.Length && !string.IsNullOrEmpty(keys[i]) ? keys[i] : null;
|
||||||
|
|
||||||
var channelName = IrcToEchoHubChannel(rawChannel);
|
var channelName = IrcToEchoHubChannel(rawChannel);
|
||||||
if (channelName is null)
|
if (channelName is null)
|
||||||
{
|
{
|
||||||
@@ -381,13 +390,31 @@ public sealed class IrcCommandHandler
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (history, error) = await _chatService.JoinChannelAsync(
|
// End-to-end encrypted channels can't be read over IRC (the gateway would
|
||||||
_conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName);
|
// have to hold the room key server-side, defeating the privacy guarantee).
|
||||||
|
var crypto = await _channelService.GetChannelCryptoAsync(channelName);
|
||||||
|
if (crypto?.IsEncrypted == true)
|
||||||
|
{
|
||||||
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_BADCHANNELKEY,
|
||||||
|
$"#{channelName} :Cannot join channel — end-to-end encrypted, use the EchoHub client");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (history, error, passwordRequired) = await _chatService.JoinChannelAsync(
|
||||||
|
_conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName, key);
|
||||||
|
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
{
|
{
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
|
if (passwordRequired)
|
||||||
$"#{channelName} :{error}");
|
{
|
||||||
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_BADCHANNELKEY,
|
||||||
|
$"#{channelName} :Cannot join channel (+k) — {error}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
|
||||||
|
$"#{channelName} :{error}");
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,8 +539,22 @@ public sealed class IrcCommandHandler
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CHANOPRIVSNEEDED,
|
var topic = msg.Parameters[1];
|
||||||
$"#{channelName} :Topic can only be changed by the channel creator via the API");
|
var result = await _channelService.UpdateTopicAsync(
|
||||||
|
_conn.UserId!.Value, channelName, string.IsNullOrWhiteSpace(topic) ? null : topic);
|
||||||
|
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
var numeric = result.Error == ChannelError.NotFound
|
||||||
|
? IrcNumericReply.ERR_NOSUCHCHANNEL
|
||||||
|
: IrcNumericReply.ERR_CHANOPRIVSNEEDED;
|
||||||
|
await _conn.SendNumericAsync(ServerName, numeric, $"#{channelName} :{result.ErrorMessage}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify SignalR clients and echo the change back to the IRC client
|
||||||
|
await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channelName);
|
||||||
|
await _conn.SendAsync($":{_conn.Hostmask} TOPIC #{channelName} :{result.Channel!.Topic ?? ""}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,10 +668,12 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
var channels = await _channelService.GetChannelListAsync();
|
var channels = await _channelService.GetChannelListAsync();
|
||||||
|
|
||||||
foreach (var ch in channels)
|
// Private channels are hidden from discovery, matching the SignalR client's channel list
|
||||||
|
foreach (var ch in channels.Where(c => c.IsPublic))
|
||||||
{
|
{
|
||||||
|
var lockHint = ch.IsProtected ? "[+k] " : "";
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LIST,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LIST,
|
||||||
$"#{ch.Name} {ch.OnlineCount} :{ch.Topic ?? ""}");
|
$"#{ch.Name} {ch.OnlineCount} :{lockHint}{ch.Topic ?? ""}");
|
||||||
}
|
}
|
||||||
|
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LISTEND,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LISTEND,
|
||||||
@@ -644,15 +687,94 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
var target = msg.Parameters[0];
|
var target = msg.Parameters[0];
|
||||||
|
|
||||||
if (target.StartsWith('#'))
|
if (!target.StartsWith('#'))
|
||||||
{
|
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CHANNELMODEIS,
|
|
||||||
$"{target} +");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UMODEIS, "+");
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UMODEIS, "+");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var channelName = IrcToEchoHubChannel(target);
|
||||||
|
if (channelName is null)
|
||||||
|
{
|
||||||
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
|
||||||
|
$"{target} :No such channel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query: MODE #channel
|
||||||
|
if (msg.Parameters.Count == 1)
|
||||||
|
{
|
||||||
|
var channel = await _channelService.GetChannelByNameAsync(channelName);
|
||||||
|
if (channel is null)
|
||||||
|
{
|
||||||
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
|
||||||
|
$"#{channelName} :No such channel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CHANNELMODEIS,
|
||||||
|
$"#{channelName} {(channel.IsProtected ? "+k" : "+")}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var modes = msg.Parameters[1];
|
||||||
|
|
||||||
|
// Clients commonly probe the ban list on join — reply with an empty list
|
||||||
|
if (modes is "b" or "+b")
|
||||||
|
{
|
||||||
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFBANLIST,
|
||||||
|
$"#{channelName} :End of channel ban list");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (modes)
|
||||||
|
{
|
||||||
|
case "+k":
|
||||||
|
if (msg.Parameters.Count < 3)
|
||||||
|
{
|
||||||
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS,
|
||||||
|
"MODE :Not enough parameters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = msg.Parameters[2];
|
||||||
|
var setResult = await _channelService.SetChannelPasswordAsync(_conn.UserId!.Value, channelName, key);
|
||||||
|
if (!setResult.IsSuccess)
|
||||||
|
{
|
||||||
|
await SendModeErrorAsync(channelName, setResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _conn.SendAsync($":{_conn.Hostmask} MODE #{channelName} +k {key}");
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "-k":
|
||||||
|
var clearResult = await _channelService.SetChannelPasswordAsync(_conn.UserId!.Value, channelName, null);
|
||||||
|
if (!clearResult.IsSuccess)
|
||||||
|
{
|
||||||
|
await SendModeErrorAsync(channelName, clearResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _conn.SendAsync($":{_conn.Hostmask} MODE #{channelName} -k *");
|
||||||
|
return;
|
||||||
|
|
||||||
|
default:
|
||||||
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNMODE,
|
||||||
|
$"{modes} :is unknown mode char to me for #{channelName}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendModeErrorAsync(string channelName, ChannelOperationResult result)
|
||||||
|
{
|
||||||
|
var numeric = result.Error switch
|
||||||
|
{
|
||||||
|
ChannelError.NotFound => IrcNumericReply.ERR_NOSUCHCHANNEL,
|
||||||
|
ChannelError.Forbidden => IrcNumericReply.ERR_CHANOPRIVSNEEDED,
|
||||||
|
_ => IrcNumericReply.ERR_KEYSET,
|
||||||
|
};
|
||||||
|
await _conn.SendNumericAsync(ServerName, numeric, $"#{channelName} :{result.ErrorMessage}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandlePingAsync(IrcMessage msg)
|
private async Task HandlePingAsync(IrcMessage msg)
|
||||||
|
|||||||
@@ -18,40 +18,49 @@ public static partial class IrcMessageFormatter
|
|||||||
var ircChannel = $"#{message.ChannelName}";
|
var ircChannel = $"#{message.ChannelName}";
|
||||||
var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub";
|
var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub";
|
||||||
|
|
||||||
switch (message.Type)
|
// Caption text first (may be empty when the message is attachments-only)
|
||||||
|
if (!string.IsNullOrEmpty(message.Content))
|
||||||
{
|
{
|
||||||
case MessageType.Text:
|
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
||||||
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
}
|
||||||
|
|
||||||
// Append embed previews if present
|
// One block per attachment
|
||||||
if (message.Embeds is { Count: > 0 })
|
if (message.Attachments is { Count: > 0 })
|
||||||
|
{
|
||||||
|
foreach (var attachment in message.Attachments)
|
||||||
|
{
|
||||||
|
switch (attachment.Kind)
|
||||||
{
|
{
|
||||||
foreach (var embed in message.Embeds)
|
case AttachmentKind.Image:
|
||||||
lines.AddRange(FormatEmbed(prefix, ircChannel, embed));
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}");
|
||||||
|
if (attachment.AsciiPreview is not null)
|
||||||
|
{
|
||||||
|
foreach (var line in attachment.AsciiPreview.Split('\n'))
|
||||||
|
{
|
||||||
|
var trimmed = line.TrimEnd('\r');
|
||||||
|
if (trimmed.Length > 0)
|
||||||
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AttachmentKind.Audio:
|
||||||
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {attachment.FileName}] {attachment.Url}");
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {attachment.FileName}] {attachment.Url}");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case MessageType.Image:
|
// Append embed previews if present
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {message.AttachmentFileName}]");
|
if (message.Embeds is { Count: > 0 })
|
||||||
if (message.AttachmentUrl is not null)
|
{
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :Download: {message.AttachmentUrl}");
|
foreach (var embed in message.Embeds)
|
||||||
|
lines.AddRange(FormatEmbed(prefix, ircChannel, embed));
|
||||||
foreach (var line in message.Content.Split('\n'))
|
|
||||||
{
|
|
||||||
var trimmed = line.TrimEnd('\r');
|
|
||||||
if (trimmed.Length > 0)
|
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case MessageType.File:
|
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case MessageType.Audio:
|
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {message.AttachmentFileName}] {message.AttachmentUrl}");
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public static class IrcNumericReply
|
|||||||
// MODE
|
// MODE
|
||||||
public const string RPL_CHANNELMODEIS = "324";
|
public const string RPL_CHANNELMODEIS = "324";
|
||||||
public const string RPL_UMODEIS = "221";
|
public const string RPL_UMODEIS = "221";
|
||||||
|
public const string RPL_ENDOFBANLIST = "368";
|
||||||
|
|
||||||
// Errors
|
// Errors
|
||||||
public const string ERR_NOSUCHNICK = "401";
|
public const string ERR_NOSUCHNICK = "401";
|
||||||
@@ -56,6 +57,9 @@ public static class IrcNumericReply
|
|||||||
public const string ERR_NEEDMOREPARAMS = "461";
|
public const string ERR_NEEDMOREPARAMS = "461";
|
||||||
public const string ERR_ALREADYREGISTERED = "462";
|
public const string ERR_ALREADYREGISTERED = "462";
|
||||||
public const string ERR_PASSWDMISMATCH = "464";
|
public const string ERR_PASSWDMISMATCH = "464";
|
||||||
|
public const string ERR_KEYSET = "467";
|
||||||
|
public const string ERR_UNKNOWNMODE = "472";
|
||||||
|
public const string ERR_BADCHANNELKEY = "475";
|
||||||
public const string ERR_CHANOPRIVSNEEDED = "482";
|
public const string ERR_CHANOPRIVSNEEDED = "482";
|
||||||
|
|
||||||
// SASL
|
// SASL
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
|
using EchoHub.Core.Security;
|
||||||
|
using EchoHub.Core.Services;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
@@ -65,7 +67,8 @@ public class ChannelsController : ControllerBase
|
|||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var result = await _channelService.CreateChannelAsync(
|
var result = await _channelService.CreateChannelAsync(
|
||||||
Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic);
|
Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic, request.Password,
|
||||||
|
request.EncryptionSalt, request.WrappedRoomKey);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
return MapChannelError(result);
|
return MapChannelError(result);
|
||||||
|
|
||||||
@@ -75,6 +78,43 @@ public class ChannelsController : ControllerBase
|
|||||||
return Created($"/api/channels/{result.Channel.Name}", result.Channel);
|
return Created($"/api/channels/{result.Channel.Name}", result.Channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public crypto metadata for a channel: whether it is end-to-end encrypted and the
|
||||||
|
/// PBKDF2 salt clients need to derive their join credential. Never returns the
|
||||||
|
/// wrapped room key — that is only handed out after a successful join.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{channel}/crypto")]
|
||||||
|
public async Task<IActionResult> GetChannelCrypto(string channel)
|
||||||
|
{
|
||||||
|
var crypto = await _channelService.GetChannelCryptoAsync(channel);
|
||||||
|
if (crypto is null)
|
||||||
|
return NotFound(new ErrorResponse($"Channel '{channel}' does not exist."));
|
||||||
|
|
||||||
|
return Ok(crypto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes an encrypted channel's passphrase by re-wrapping its room key.
|
||||||
|
/// The caller proves knowledge of the old passphrase via the old auth key;
|
||||||
|
/// history is never re-encrypted (the room content key does not change).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{channel}/rekey")]
|
||||||
|
public async Task<IActionResult> RekeyChannel(string channel, [FromBody] RekeyChannelRequest request)
|
||||||
|
{
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
|
var result = await _channelService.RekeyChannelAsync(
|
||||||
|
Guid.Parse(userIdClaim), channel,
|
||||||
|
request.OldPassword, request.NewPassword,
|
||||||
|
request.NewEncryptionSalt, request.NewWrappedRoomKey);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
return MapChannelError(result);
|
||||||
|
|
||||||
|
return Ok(result.Channel);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("{channel}/topic")]
|
[HttpPut("{channel}/topic")]
|
||||||
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
|
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
|
||||||
{
|
{
|
||||||
@@ -105,11 +145,18 @@ public class ChannelsController : ControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{channel}/upload")]
|
/// <summary>
|
||||||
|
/// Sends one message carrying optional text (<c>content</c> form field) plus zero or more
|
||||||
|
/// file attachments (Discord-style). For non-encrypted channels the server sniffs each file's
|
||||||
|
/// kind and renders ASCII previews for images. For end-to-end encrypted channels the client
|
||||||
|
/// uploads ciphertext blobs and declares each file's kind (<c>kind</c>) and pre-rendered,
|
||||||
|
/// room-encrypted preview (<c>preview</c>), aligned by file order — the server never inspects them.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{channel}/messages")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
[RequestSizeLimit(HubConstants.MaxFileSizeBytes)]
|
[RequestSizeLimit((long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
|
||||||
[RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)]
|
[RequestFormLimits(MultipartBodyLengthLimit = (long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
|
||||||
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
|
public async Task<IActionResult> SendMessageWithAttachments(string channel, [FromQuery] string? size = null)
|
||||||
{
|
{
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var usernameClaim = User.FindFirstValue("username");
|
var usernameClaim = User.FindFirstValue("username");
|
||||||
@@ -126,80 +173,136 @@ public class ChannelsController : ControllerBase
|
|||||||
if (channelDto is null)
|
if (channelDto is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
if (!Request.HasFormContentType)
|
||||||
return BadRequest(new ErrorResponse("No file uploaded."));
|
return BadRequest(new ErrorResponse("Expected multipart form data."));
|
||||||
|
|
||||||
var file = Request.Form.Files[0];
|
var files = Request.Form.Files;
|
||||||
|
if (files.Count == 0)
|
||||||
|
return BadRequest(new ErrorResponse("At least one attachment is required. Send plain text over the chat connection."));
|
||||||
|
if (files.Count > HubConstants.MaxAttachmentsPerMessage)
|
||||||
|
return BadRequest(new ErrorResponse($"A message may carry at most {HubConstants.MaxAttachmentsPerMessage} attachments."));
|
||||||
|
|
||||||
// Detect file type early so we can apply the correct size limit
|
|
||||||
using var stream = file.OpenReadStream();
|
|
||||||
var isImage = FileValidationHelper.IsValidImage(stream);
|
|
||||||
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
|
||||||
|
|
||||||
var maxSize = isImage ? HubConstants.MaxImageSizeBytes
|
|
||||||
: isAudio ? HubConstants.MaxAudioFileSizeBytes
|
|
||||||
: HubConstants.MaxFileSizeBytes;
|
|
||||||
|
|
||||||
if (file.Length > maxSize)
|
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB."));
|
|
||||||
|
|
||||||
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
|
||||||
|
|
||||||
var messageType = isImage ? MessageType.Image
|
|
||||||
: isAudio ? MessageType.Audio
|
|
||||||
: MessageType.File;
|
|
||||||
string content;
|
|
||||||
|
|
||||||
if (isImage)
|
|
||||||
{
|
|
||||||
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
|
||||||
using var imageStream = System.IO.File.OpenRead(filePath);
|
|
||||||
content = _asciiService.ConvertToAscii(imageStream, w, h);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
content = file.FileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
var attachmentUrl = $"/api/files/{fileId}";
|
|
||||||
var sender = await _db.Users.FindAsync(userId);
|
var sender = await _db.Users.FindAsync(userId);
|
||||||
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
if (sender is not null && sender.IsMuted && (sender.MutedUntil is null || sender.MutedUntil > DateTimeOffset.UtcNow))
|
||||||
|
return StatusCode(403, new ErrorResponse("You are muted and cannot send messages."));
|
||||||
|
|
||||||
|
// Caption: plaintext for normal channels, $RC1$ room-ciphertext for encrypted ones.
|
||||||
|
// Decrypt() is a pass-through when there is no transport prefix.
|
||||||
|
var content = _encryption.Decrypt(Request.Form["content"].ToString());
|
||||||
|
var isRoomCiphertext = RoomCrypto.IsRoomCiphertext(content);
|
||||||
|
if (!isRoomCiphertext && content.Length > HubConstants.MaxMessageLength)
|
||||||
|
return BadRequest(new ErrorResponse($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."));
|
||||||
|
|
||||||
|
var declaredKinds = Request.Form["kind"];
|
||||||
|
var declaredPreviews = Request.Form["preview"];
|
||||||
|
|
||||||
|
var attachmentEntities = new List<Attachment>();
|
||||||
|
var attachmentDtos = new List<AttachmentDto>();
|
||||||
|
|
||||||
|
for (var i = 0; i < files.Count; i++)
|
||||||
|
{
|
||||||
|
var file = files[i];
|
||||||
|
AttachmentKind kind;
|
||||||
|
string? previewPlain;
|
||||||
|
string fileId;
|
||||||
|
|
||||||
|
if (channelDto.IsEncrypted)
|
||||||
|
{
|
||||||
|
// Ciphertext blob — trust the client's declared kind + room-encrypted preview.
|
||||||
|
// Client sends one kind + preview per file in order; empty preview means none.
|
||||||
|
kind = ParseKind(i < declaredKinds.Count ? declaredKinds[i] : null);
|
||||||
|
previewPlain = i < declaredPreviews.Count ? declaredPreviews[i] : null;
|
||||||
|
if (string.IsNullOrEmpty(previewPlain))
|
||||||
|
previewPlain = null;
|
||||||
|
|
||||||
|
if (file.Length > MaxForKind(kind))
|
||||||
|
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size."));
|
||||||
|
|
||||||
|
using var encryptedStream = file.OpenReadStream();
|
||||||
|
(fileId, _) = await _fileStorage.SaveFileAsync(encryptedStream, file.FileName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var stream = file.OpenReadStream();
|
||||||
|
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||||
|
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
||||||
|
kind = isImage ? AttachmentKind.Image : isAudio ? AttachmentKind.Audio : AttachmentKind.File;
|
||||||
|
|
||||||
|
if (file.Length > MaxForKind(kind))
|
||||||
|
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {MaxForKind(kind) / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
|
string filePath;
|
||||||
|
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
||||||
|
|
||||||
|
if (isImage)
|
||||||
|
{
|
||||||
|
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||||
|
using var imageStream = System.IO.File.OpenRead(filePath);
|
||||||
|
previewPlain = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
previewPlain = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = $"/api/files/{fileId}";
|
||||||
|
attachmentEntities.Add(new Attachment
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Kind = kind,
|
||||||
|
Url = url,
|
||||||
|
FileName = file.FileName,
|
||||||
|
FileSize = file.Length,
|
||||||
|
AsciiPreview = _encryption.EncryptDatabaseEnabled ? _encryption.EncryptNullable(previewPlain) : previewPlain,
|
||||||
|
});
|
||||||
|
attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length,
|
||||||
|
_encryption.EncryptNullable(previewPlain)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Content = dbContent,
|
Content = dbContent,
|
||||||
Type = messageType,
|
|
||||||
AttachmentUrl = attachmentUrl,
|
|
||||||
AttachmentFileName = file.FileName,
|
|
||||||
AttachmentFileSize = file.Length,
|
|
||||||
SentAt = DateTimeOffset.UtcNow,
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
ChannelId = channelDto.Id,
|
ChannelId = channelDto.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = usernameClaim,
|
SenderUsername = usernameClaim,
|
||||||
|
Attachments = attachmentEntities,
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.Messages.Add(message);
|
_db.Messages.Add(message);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Encrypt for transport — clients decrypt
|
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
_encryption.Encrypt(content),
|
_encryption.Encrypt(content),
|
||||||
message.SenderUsername,
|
message.SenderUsername,
|
||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
messageType,
|
|
||||||
attachmentUrl,
|
|
||||||
file.FileName,
|
|
||||||
message.SentAt,
|
message.SentAt,
|
||||||
file.Length);
|
attachmentDtos);
|
||||||
|
|
||||||
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||||
|
|
||||||
return Ok(messageDto);
|
return Ok(messageDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static AttachmentKind ParseKind(string? kind) => kind?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"image" => AttachmentKind.Image,
|
||||||
|
"audio" => AttachmentKind.Audio,
|
||||||
|
_ => AttachmentKind.File,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static long MaxForKind(AttachmentKind kind) => kind switch
|
||||||
|
{
|
||||||
|
AttachmentKind.Image => HubConstants.MaxImageSizeBytes,
|
||||||
|
AttachmentKind.Audio => HubConstants.MaxAudioFileSizeBytes,
|
||||||
|
_ => HubConstants.MaxFileSizeBytes,
|
||||||
|
};
|
||||||
|
|
||||||
[HttpPost("{channel}/send-url")]
|
[HttpPost("{channel}/send-url")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
|
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
|
||||||
@@ -219,6 +322,10 @@ public class ChannelsController : ControllerBase
|
|||||||
if (channelDto is null)
|
if (channelDto is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
|
if (channelDto.IsEncrypted)
|
||||||
|
return BadRequest(new ErrorResponse(
|
||||||
|
"Sending images by URL is not available in end-to-end encrypted channels — download the image and /send the file instead."));
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Url))
|
if (string.IsNullOrWhiteSpace(request.Url))
|
||||||
return BadRequest(new ErrorResponse("URL is required."));
|
return BadRequest(new ErrorResponse("URL is required."));
|
||||||
|
|
||||||
@@ -276,46 +383,49 @@ public class ChannelsController : ControllerBase
|
|||||||
// Save file and convert to ASCII
|
// Save file and convert to ASCII
|
||||||
var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName);
|
var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName);
|
||||||
|
|
||||||
string content;
|
string preview;
|
||||||
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||||
using (var imageStream = System.IO.File.OpenRead(filePath))
|
using (var imageStream = System.IO.File.OpenRead(filePath))
|
||||||
{
|
{
|
||||||
content = _asciiService.ConvertToAscii(imageStream, w, h);
|
preview = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
var attachmentUrl = $"/api/files/{fileId}";
|
var attachmentUrl = $"/api/files/{fileId}";
|
||||||
var sender = await _db.Users.FindAsync(userId);
|
var sender = await _db.Users.FindAsync(userId);
|
||||||
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
|
||||||
|
// A URL-shared image is a message with no caption and one image attachment.
|
||||||
|
var attachment = new Attachment
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Kind = AttachmentKind.Image,
|
||||||
|
Url = attachmentUrl,
|
||||||
|
FileName = fileName,
|
||||||
|
FileSize = imageBytes.Length,
|
||||||
|
AsciiPreview = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(preview) : preview,
|
||||||
|
};
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Content = dbContent,
|
Content = string.Empty,
|
||||||
Type = MessageType.Image,
|
|
||||||
AttachmentUrl = attachmentUrl,
|
|
||||||
AttachmentFileName = fileName,
|
|
||||||
AttachmentFileSize = imageBytes.Length,
|
|
||||||
SentAt = DateTimeOffset.UtcNow,
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
ChannelId = channelDto.Id,
|
ChannelId = channelDto.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = usernameClaim,
|
SenderUsername = usernameClaim,
|
||||||
|
Attachments = [attachment],
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.Messages.Add(message);
|
_db.Messages.Add(message);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Encrypt for transport — clients decrypt
|
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
_encryption.Encrypt(content),
|
_encryption.Encrypt(string.Empty),
|
||||||
message.SenderUsername,
|
message.SenderUsername,
|
||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
MessageType.Image,
|
|
||||||
attachmentUrl,
|
|
||||||
fileName,
|
|
||||||
message.SentAt,
|
message.SentAt,
|
||||||
imageBytes.Length);
|
[new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))]);
|
||||||
|
|
||||||
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||||
|
|
||||||
|
|||||||
@@ -20,17 +20,20 @@ public class ModerationController : ControllerBase
|
|||||||
private readonly EchoHubDbContext _db;
|
private readonly EchoHubDbContext _db;
|
||||||
private readonly IChatService _chatService;
|
private readonly IChatService _chatService;
|
||||||
private readonly PresenceTracker _presenceTracker;
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly FileStorageService _fileStorage;
|
||||||
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
|
|
||||||
public ModerationController(
|
public ModerationController(
|
||||||
EchoHubDbContext db,
|
EchoHubDbContext db,
|
||||||
IChatService chatService,
|
IChatService chatService,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
|
FileStorageService fileStorage,
|
||||||
IEnumerable<IChatBroadcaster> broadcasters)
|
IEnumerable<IChatBroadcaster> broadcasters)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
|
_fileStorage = fileStorage;
|
||||||
_broadcasters = broadcasters;
|
_broadcasters = broadcasters;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,17 +173,47 @@ public class ModerationController : ControllerBase
|
|||||||
[HttpDelete("messages/{messageId:guid}")]
|
[HttpDelete("messages/{messageId:guid}")]
|
||||||
public async Task<IActionResult> DeleteMessage(Guid messageId)
|
public async Task<IActionResult> DeleteMessage(Guid messageId)
|
||||||
{
|
{
|
||||||
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
// Any authenticated user may reach this; permission depends on authorship + role hierarchy.
|
||||||
if (error is not null) return error;
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
|
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
|
||||||
|
if (caller is null)
|
||||||
|
return Unauthorized(new ErrorResponse("User not found."));
|
||||||
|
|
||||||
var message = await _db.Messages
|
var message = await _db.Messages
|
||||||
.Include(m => m.Channel)
|
.Include(m => m.Channel)
|
||||||
|
.Include(m => m.Attachments)
|
||||||
.FirstOrDefaultAsync(m => m.Id == messageId);
|
.FirstOrDefaultAsync(m => m.Id == messageId);
|
||||||
|
|
||||||
if (message is null)
|
if (message is null)
|
||||||
return NotFound(new ErrorResponse("Message not found."));
|
return NotFound(new ErrorResponse("Message not found."));
|
||||||
|
|
||||||
|
var isOwnMessage = message.SenderUserId == caller.Id;
|
||||||
|
if (!isOwnMessage)
|
||||||
|
{
|
||||||
|
// Deleting someone else's message requires Mod+ AND a strictly higher role than
|
||||||
|
// the message author (so a mod can't delete an admin's/owner's message).
|
||||||
|
if (caller.Role < ServerRole.Mod)
|
||||||
|
return StatusCode(403, new ErrorResponse("You can only delete your own messages."));
|
||||||
|
|
||||||
|
var author = await _db.Users.FindAsync(message.SenderUserId);
|
||||||
|
var authorRole = author?.Role ?? ServerRole.Member;
|
||||||
|
if (authorRole >= caller.Role)
|
||||||
|
return StatusCode(403, new ErrorResponse("You cannot delete a message from a user with an equal or higher role."));
|
||||||
|
}
|
||||||
|
|
||||||
var channelName = message.Channel!.Name;
|
var channelName = message.Channel!.Name;
|
||||||
|
|
||||||
|
// Remove attachment blobs from disk before the DB rows cascade away.
|
||||||
|
foreach (var attachment in message.Attachments)
|
||||||
|
{
|
||||||
|
var fileId = attachment.Url.Split('/').LastOrDefault();
|
||||||
|
if (!string.IsNullOrEmpty(fileId))
|
||||||
|
_fileStorage.DeleteFile(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
_db.Messages.Remove(message);
|
_db.Messages.Remove(message);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -200,7 +233,19 @@ public class ModerationController : ControllerBase
|
|||||||
if (dbChannel is null)
|
if (dbChannel is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
var messages = await _db.Messages.Where(m => m.ChannelId == dbChannel.Id).ToListAsync();
|
var messages = await _db.Messages
|
||||||
|
.Where(m => m.ChannelId == dbChannel.Id)
|
||||||
|
.Include(m => m.Attachments)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var fileId in messages
|
||||||
|
.SelectMany(m => m.Attachments)
|
||||||
|
.Select(a => a.Url.Split('/').LastOrDefault())
|
||||||
|
.Where(id => !string.IsNullOrEmpty(id)))
|
||||||
|
{
|
||||||
|
_fileStorage.DeleteFile(fileId!);
|
||||||
|
}
|
||||||
|
|
||||||
_db.Messages.RemoveRange(messages);
|
_db.Messages.RemoveRange(messages);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
@@ -13,11 +16,13 @@ public class ServerController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly EchoHubDbContext _db;
|
private readonly EchoHubDbContext _db;
|
||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
|
private readonly DirectoryClaimStore _claimStore;
|
||||||
|
|
||||||
public ServerController(EchoHubDbContext db, IConfiguration config)
|
public ServerController(EchoHubDbContext db, IConfiguration config, DirectoryClaimStore claimStore)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_config = config;
|
_config = config;
|
||||||
|
_claimStore = claimStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("info")]
|
[HttpGet("info")]
|
||||||
@@ -47,4 +52,46 @@ public class ServerController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new EncryptionKeyResponse(key));
|
return Ok(new EncryptionKeyResponse(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Operator-facing view of the EchoHubSpace directory registration: ServerId for admin
|
||||||
|
/// support tickets, current registration state, and the last error/conflict if any.
|
||||||
|
/// Never exposes the claim token itself.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("directory")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> GetDirectoryStatus()
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var status = _claimStore.Status;
|
||||||
|
var response = new
|
||||||
|
{
|
||||||
|
ServerId = _claimStore.ServerId,
|
||||||
|
HasClaimToken = _claimStore.ClaimToken is not null,
|
||||||
|
status.IsRegistered,
|
||||||
|
status.LastRegisteredAt,
|
||||||
|
status.LastError,
|
||||||
|
status.ConflictingHosts,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole)
|
||||||
|
{
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return (null, Unauthorized(new ErrorResponse("Authentication required.")));
|
||||||
|
|
||||||
|
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
|
||||||
|
if (caller is null)
|
||||||
|
return (null, Unauthorized(new ErrorResponse("User not found.")));
|
||||||
|
|
||||||
|
if (caller.Role < minimumRole)
|
||||||
|
return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher.")));
|
||||||
|
|
||||||
|
return (caller, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
|
using EchoHub.Core.Services;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public class EchoHubDbContext : DbContext
|
|||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
public DbSet<Channel> Channels => Set<Channel>();
|
public DbSet<Channel> Channels => Set<Channel>();
|
||||||
public DbSet<Message> Messages => Set<Message>();
|
public DbSet<Message> Messages => Set<Message>();
|
||||||
|
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
|
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
|
||||||
|
|
||||||
@@ -44,6 +45,9 @@ public class EchoHubDbContext : DbContext
|
|||||||
entity.HasIndex(c => c.Name).IsUnique();
|
entity.HasIndex(c => c.Name).IsUnique();
|
||||||
entity.Property(c => c.Name).IsRequired().HasMaxLength(100);
|
entity.Property(c => c.Name).IsRequired().HasMaxLength(100);
|
||||||
entity.Property(c => c.Topic).HasMaxLength(500);
|
entity.Property(c => c.Topic).HasMaxLength(500);
|
||||||
|
entity.Property(c => c.PasswordHash).HasMaxLength(100);
|
||||||
|
entity.Property(c => c.EncryptionSalt).HasMaxLength(64);
|
||||||
|
entity.Property(c => c.WrappedRoomKey).HasMaxLength(200);
|
||||||
|
|
||||||
entity.HasMany(c => c.Messages)
|
entity.HasMany(c => c.Messages)
|
||||||
.WithOne(m => m.Channel)
|
.WithOne(m => m.Channel)
|
||||||
@@ -60,6 +64,21 @@ public class EchoHubDbContext : DbContext
|
|||||||
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
||||||
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
||||||
entity.Property(m => m.EmbedJson).HasMaxLength(32000); // Increased for encrypted embed JSON
|
entity.Property(m => m.EmbedJson).HasMaxLength(32000); // Increased for encrypted embed JSON
|
||||||
|
|
||||||
|
entity.HasMany(m => m.Attachments)
|
||||||
|
.WithOne(a => a.Message)
|
||||||
|
.HasForeignKey(a => a.MessageId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Attachment>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(a => a.Id);
|
||||||
|
entity.HasIndex(a => a.MessageId);
|
||||||
|
entity.Property(a => a.Kind).HasConversion<int>();
|
||||||
|
entity.Property(a => a.Url).IsRequired().HasMaxLength(500);
|
||||||
|
entity.Property(a => a.FileName).IsRequired().HasMaxLength(255);
|
||||||
|
entity.Property(a => a.AsciiPreview).HasMaxLength(64000); // color-tag ASCII art, encrypted-at-rest overhead
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<ChannelMembership>(entity =>
|
modelBuilder.Entity<ChannelMembership>(entity =>
|
||||||
|
|||||||
+271
@@ -0,0 +1,271 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260715232856_AddChannelPasswordHash")]
|
||||||
|
partial class AddChannelPasswordHash
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("JoinedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("ChannelMemberships");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("AttachmentFileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(32000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddChannelPasswordHash : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PasswordHash",
|
||||||
|
table: "Channels",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PasswordHash",
|
||||||
|
table: "Channels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+279
@@ -0,0 +1,279 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260716012917_AddChannelEncryptionEnvelope")]
|
||||||
|
partial class AddChannelEncryptionEnvelope
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptionSalt")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("WrappedRoomKey")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("JoinedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("ChannelMemberships");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("AttachmentFileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(32000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddChannelEncryptionEnvelope : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "EncryptionSalt",
|
||||||
|
table: "Channels",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "WrappedRoomKey",
|
||||||
|
table: "Channels",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EncryptionSalt",
|
||||||
|
table: "Channels");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "WrappedRoomKey",
|
||||||
|
table: "Channels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+331
@@ -0,0 +1,331 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260716020211_AddMessageAttachments")]
|
||||||
|
partial class AddMessageAttachments
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AsciiPreview")
|
||||||
|
.HasMaxLength(64000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MessageId");
|
||||||
|
|
||||||
|
b.ToTable("Attachments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptionSalt")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("WrappedRoomKey")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("JoinedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("ChannelMemberships");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("AttachmentFileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(32000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Message", "Message")
|
||||||
|
.WithMany("Attachments")
|
||||||
|
.HasForeignKey("MessageId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Message");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Attachments");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddMessageAttachments : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Attachments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
MessageId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Kind = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||||
|
FileName = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||||
|
FileSize = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
AsciiPreview = table.Column<string>(type: "TEXT", maxLength: 64000, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Attachments", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Attachments_Messages_MessageId",
|
||||||
|
column: x => x.MessageId,
|
||||||
|
principalTable: "Messages",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Attachments_MessageId",
|
||||||
|
table: "Attachments",
|
||||||
|
column: "MessageId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Attachments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,42 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AsciiPreview")
|
||||||
|
.HasMaxLength(64000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MessageId");
|
||||||
|
|
||||||
|
b.ToTable("Attachments");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -29,6 +65,10 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.Property<Guid>("CreatedByUserId")
|
b.Property<Guid>("CreatedByUserId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptionSalt")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<bool>("IsPublic")
|
b.Property<bool>("IsPublic")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -37,10 +77,18 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Topic")
|
b.Property<string>("Topic")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("WrappedRoomKey")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Name")
|
b.HasIndex("Name")
|
||||||
@@ -217,6 +265,17 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.ToTable("Users");
|
b.ToTable("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Message", "Message")
|
||||||
|
.WithMany("Attachments")
|
||||||
|
.HasForeignKey("MessageId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Message");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("EchoHub.Core.Models.Channel", null)
|
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||||
@@ -258,6 +317,11 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("Messages");
|
b.Navigation("Messages");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Attachments");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ public class ChatHub : Hub<IEchoHubClient>
|
|||||||
private readonly IChatService _chatService;
|
private readonly IChatService _chatService;
|
||||||
private readonly ILogger<ChatHub> _logger;
|
private readonly ILogger<ChatHub> _logger;
|
||||||
|
|
||||||
public ChatHub(IChatService chatService, ILogger<ChatHub> logger)
|
private readonly IChannelService _channelService;
|
||||||
|
|
||||||
|
public ChatHub(IChatService chatService, IChannelService channelService, ILogger<ChatHub> logger)
|
||||||
{
|
{
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
|
_channelService = channelService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,18 +59,23 @@ public class ChatHub : Hub<IEchoHubClient>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<JoinChannelResult> JoinChannel(string channelName)
|
public async Task<JoinChannelResult> JoinChannel(string channelName, string? password = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (history, error) = await _chatService.JoinChannelAsync(
|
var (history, error, passwordRequired) = await _chatService.JoinChannelAsync(
|
||||||
Context.ConnectionId, CurrentUserId, CurrentUsername, channelName);
|
Context.ConnectionId, CurrentUserId, CurrentUsername, channelName, password);
|
||||||
|
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
return new JoinChannelResult(false, [], error);
|
return new JoinChannelResult(false, [], error, passwordRequired);
|
||||||
|
|
||||||
await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim());
|
await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim());
|
||||||
return new JoinChannelResult(true, history);
|
|
||||||
|
// Members of encrypted channels receive the key envelope so they can unwrap
|
||||||
|
// the room content key with their passphrase (the server can't).
|
||||||
|
var (encryptionSalt, wrappedRoomKey) = await _channelService.GetChannelKeyEnvelopeAsync(channelName);
|
||||||
|
return new JoinChannelResult(true, history,
|
||||||
|
EncryptionSalt: encryptionSalt, WrappedRoomKey: wrappedRoomKey);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -106,11 +114,11 @@ public class ChatHub : Hub<IEchoHubClient>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount)
|
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _chatService.GetChannelHistoryAsync(channelName, count);
|
return await _chatService.GetChannelHistoryAsync(channelName, count, offset);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Text;
|
|||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
|
using EchoHub.Core.Services;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Auth;
|
using EchoHub.Server.Auth;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
@@ -107,6 +108,7 @@ while (true)
|
|||||||
builder.Services.AddSingleton<ImageToAsciiService>();
|
builder.Services.AddSingleton<ImageToAsciiService>();
|
||||||
builder.Services.AddSingleton<FileStorageService>();
|
builder.Services.AddSingleton<FileStorageService>();
|
||||||
builder.Services.AddSingleton<LinkEmbedService>();
|
builder.Services.AddSingleton<LinkEmbedService>();
|
||||||
|
builder.Services.AddSingleton<DirectoryClaimStore>();
|
||||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||||
builder.Services.AddHostedService<FileCleanupService>();
|
builder.Services.AddHostedService<FileCleanupService>();
|
||||||
builder.Services.AddHostedService<MuteExpirationService>();
|
builder.Services.AddHostedService<MuteExpirationService>();
|
||||||
|
|||||||
@@ -41,14 +41,16 @@ public class ChannelService : IChannelService
|
|||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(limit)
|
.Take(limit)
|
||||||
.Select(c => new ChannelDto(
|
.Select(c => new ChannelDto(
|
||||||
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt))
|
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt,
|
||||||
|
c.PasswordHash != null, c.WrappedRoomKey != null))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
|
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ChannelOperationResult> CreateChannelAsync(
|
public async Task<ChannelOperationResult> CreateChannelAsync(
|
||||||
Guid creatorUserId, string name, string? topic, bool isPublic)
|
Guid creatorUserId, string name, string? topic, bool isPublic,
|
||||||
|
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required.");
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required.");
|
||||||
@@ -59,6 +61,16 @@ public class ChannelService : IChannelService
|
|||||||
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||||
"Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.");
|
"Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.");
|
||||||
|
|
||||||
|
var passwordError = ValidateChannelPassword(ref password);
|
||||||
|
if (passwordError is not null)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError);
|
||||||
|
|
||||||
|
// The E2E envelope (client-generated) only makes sense on password-gated channels
|
||||||
|
var hasEnvelope = !string.IsNullOrWhiteSpace(encryptionSalt) && !string.IsNullOrWhiteSpace(wrappedRoomKey);
|
||||||
|
if (hasEnvelope && password is null)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||||
|
"Encrypted channels require a password.");
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
@@ -72,6 +84,9 @@ public class ChannelService : IChannelService
|
|||||||
Topic = topic?.Trim(),
|
Topic = topic?.Trim(),
|
||||||
IsPublic = isPublic,
|
IsPublic = isPublic,
|
||||||
CreatedByUserId = creatorUserId,
|
CreatedByUserId = creatorUserId,
|
||||||
|
PasswordHash = password is not null ? BCrypt.Net.BCrypt.HashPassword(password) : null,
|
||||||
|
EncryptionSalt = hasEnvelope ? encryptionSalt : null,
|
||||||
|
WrappedRoomKey = hasEnvelope ? wrappedRoomKey : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Channels.Add(channel);
|
db.Channels.Add(channel);
|
||||||
@@ -85,7 +100,8 @@ public class ChannelService : IChannelService
|
|||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
|
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt,
|
||||||
|
channel.PasswordHash != null, channel.WrappedRoomKey != null);
|
||||||
return ChannelOperationResult.Success(dto);
|
return ChannelOperationResult.Success(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +128,94 @@ public class ChannelService : IChannelService
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
||||||
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
|
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt,
|
||||||
|
dbChannel.PasswordHash != null, dbChannel.WrappedRoomKey != null);
|
||||||
|
return ChannelOperationResult.Success(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets, changes, or clears (null) a channel's join password. Creator or admin only.
|
||||||
|
/// Not available on end-to-end encrypted channels — those change passphrase via
|
||||||
|
/// <see cref="RekeyChannelAsync"/> so the room key envelope stays consistent.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
var passwordError = ValidateChannelPassword(ref password);
|
||||||
|
if (passwordError is not null)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError);
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
if (dbChannel is null)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
|
||||||
|
|
||||||
|
if (dbChannel.WrappedRoomKey is not null)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.Protected,
|
||||||
|
"This channel is end-to-end encrypted — change its passphrase from the EchoHub client (/passwd).");
|
||||||
|
|
||||||
|
var caller = await db.Users.FindAsync(callerUserId);
|
||||||
|
if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.Forbidden,
|
||||||
|
"Only the channel creator or an admin can change the channel password.");
|
||||||
|
|
||||||
|
dbChannel.PasswordHash = password is not null ? BCrypt.Net.BCrypt.HashPassword(password) : null;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
||||||
|
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt,
|
||||||
|
dbChannel.PasswordHash != null, dbChannel.WrappedRoomKey != null);
|
||||||
|
return ChannelOperationResult.Success(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes an encrypted channel's passphrase by swapping the join-gate hash and the
|
||||||
|
/// wrapped room key. The room content key itself never changes, so history stays
|
||||||
|
/// readable — the client re-wraps it under the new passphrase-derived key.
|
||||||
|
/// Creator only: admins cannot rekey a room whose passphrase they don't know.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ChannelOperationResult> RekeyChannelAsync(Guid callerUserId, string channelName,
|
||||||
|
string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
string? validatedNew = newPassword;
|
||||||
|
var passwordError = ValidateChannelPassword(ref validatedNew);
|
||||||
|
if (passwordError is not null)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError);
|
||||||
|
if (validatedNew is null || string.IsNullOrWhiteSpace(newEncryptionSalt) || string.IsNullOrWhiteSpace(newWrappedRoomKey))
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||||
|
"New password, salt, and wrapped room key are required.");
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
if (dbChannel is null)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
|
||||||
|
|
||||||
|
if (dbChannel.WrappedRoomKey is null || dbChannel.PasswordHash is null)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||||
|
"This channel is not end-to-end encrypted.");
|
||||||
|
|
||||||
|
if (dbChannel.CreatedByUserId != callerUserId)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.Forbidden,
|
||||||
|
"Only the channel creator can change the passphrase.");
|
||||||
|
|
||||||
|
if (!BCrypt.Net.BCrypt.Verify(oldPassword, dbChannel.PasswordHash))
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.Forbidden, "The current passphrase is incorrect.");
|
||||||
|
|
||||||
|
dbChannel.PasswordHash = BCrypt.Net.BCrypt.HashPassword(validatedNew);
|
||||||
|
dbChannel.EncryptionSalt = newEncryptionSalt;
|
||||||
|
dbChannel.WrappedRoomKey = newWrappedRoomKey;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
||||||
|
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt,
|
||||||
|
true, true);
|
||||||
return ChannelOperationResult.Success(dto);
|
return ChannelOperationResult.Success(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +242,8 @@ public class ChannelService : IChannelService
|
|||||||
db.Channels.Remove(dbChannel);
|
db.Channels.Remove(dbChannel);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt);
|
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt,
|
||||||
|
dbChannel.PasswordHash != null, dbChannel.WrappedRoomKey != null);
|
||||||
return ChannelOperationResult.Success(dto);
|
return ChannelOperationResult.Success(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +269,8 @@ public class ChannelService : IChannelService
|
|||||||
|
|
||||||
return channels.Select(c => new ChannelListItem(
|
return channels.Select(c => new ChannelListItem(
|
||||||
c.Name, c.Topic,
|
c.Name, c.Topic,
|
||||||
_presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
|
_presenceTracker.GetOnlineUsersInChannel(c.Name).Count,
|
||||||
|
c.IsPublic, c.PasswordHash != null)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ChannelDto?> GetChannelByNameAsync(string channelName)
|
public async Task<ChannelDto?> GetChannelByNameAsync(string channelName)
|
||||||
@@ -179,15 +284,41 @@ public class ChannelService : IChannelService
|
|||||||
if (c is null) return null;
|
if (c is null) return null;
|
||||||
|
|
||||||
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
|
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
|
||||||
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt);
|
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt,
|
||||||
|
c.PasswordHash != null, c.WrappedRoomKey != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName)
|
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName);
|
||||||
|
if (c is null) return null;
|
||||||
|
|
||||||
|
return new ChannelCryptoDto(c.WrappedRoomKey != null, c.EncryptionSalt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName);
|
||||||
|
return (c?.EncryptionSalt, c?.WrappedRoomKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(
|
||||||
|
Guid userId, string channelName, string? password = null)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
|
return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.", false);
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
@@ -211,7 +342,7 @@ public class ChannelService : IChannelService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.");
|
return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.", false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +350,17 @@ public class ChannelService : IChannelService
|
|||||||
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
|
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
|
||||||
if (!hasMembership)
|
if (!hasMembership)
|
||||||
{
|
{
|
||||||
|
// Password gate: existing members (incl. the creator) joined before, so only
|
||||||
|
// first-time joins of a protected channel need the password.
|
||||||
|
if (channel.PasswordHash is not null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(password))
|
||||||
|
return (false, $"Channel '{channelName}' is password protected.", true);
|
||||||
|
|
||||||
|
if (!BCrypt.Net.BCrypt.Verify(password, channel.PasswordHash))
|
||||||
|
return (false, $"Incorrect password for channel '{channelName}'.", true);
|
||||||
|
}
|
||||||
|
|
||||||
db.ChannelMemberships.Add(new ChannelMembership
|
db.ChannelMemberships.Add(new ChannelMembership
|
||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
@@ -227,7 +369,28 @@ public class ChannelService : IChannelService
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (true, null);
|
return (true, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes and validates a channel password. Whitespace-only becomes null (no password).
|
||||||
|
/// Returns an error message, or null when valid.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ValidateChannelPassword(ref string? password)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
password = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.Length < ValidationConstants.MinChannelPasswordLength)
|
||||||
|
return $"Channel password must be at least {ValidationConstants.MinChannelPasswordLength} characters.";
|
||||||
|
|
||||||
|
if (password.Length > ValidationConstants.MaxPasswordLength)
|
||||||
|
return $"Channel password must not exceed {ValidationConstants.MaxPasswordLength} characters.";
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
|
private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class ChatService : IChatService
|
|||||||
private readonly LinkEmbedService _embedService;
|
private readonly LinkEmbedService _embedService;
|
||||||
private readonly IMessageEncryptionService _encryption;
|
private readonly IMessageEncryptionService _encryption;
|
||||||
private readonly IChannelService _channelService;
|
private readonly IChannelService _channelService;
|
||||||
|
private readonly FileStorageService _fileStorage;
|
||||||
private readonly ILogger<ChatService> _logger;
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
public ChatService(
|
public ChatService(
|
||||||
@@ -27,6 +28,7 @@ public class ChatService : IChatService
|
|||||||
LinkEmbedService embedService,
|
LinkEmbedService embedService,
|
||||||
IMessageEncryptionService encryption,
|
IMessageEncryptionService encryption,
|
||||||
IChannelService channelService,
|
IChannelService channelService,
|
||||||
|
FileStorageService fileStorage,
|
||||||
ILogger<ChatService> logger)
|
ILogger<ChatService> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
@@ -35,6 +37,7 @@ public class ChatService : IChatService
|
|||||||
_embedService = embedService;
|
_embedService = embedService;
|
||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
_channelService = channelService;
|
_channelService = channelService;
|
||||||
|
_fileStorage = fileStorage;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,15 +96,15 @@ public class ChatService : IChatService
|
|||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(
|
public async Task<(List<MessageDto> History, string? Error, bool PasswordRequired)> JoinChannelAsync(
|
||||||
string connectionId, Guid userId, string username, string channelName)
|
string connectionId, Guid userId, string username, string channelName, string? password = null)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
// Delegate channel validation + membership to ChannelService
|
// Delegate channel validation + membership (incl. password gate) to ChannelService
|
||||||
var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName);
|
var (success, error, passwordRequired) = await _channelService.EnsureChannelMembershipAsync(userId, channelName, password);
|
||||||
if (!success)
|
if (!success)
|
||||||
return ([], error);
|
return ([], error, passwordRequired);
|
||||||
|
|
||||||
var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
|
var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
|
||||||
|
|
||||||
@@ -136,7 +139,7 @@ public class ChatService : IChatService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
|
var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
|
||||||
return (history, null);
|
return (history, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LeaveChannelAsync(string connectionId, string username, string channelName)
|
public async Task LeaveChannelAsync(string connectionId, string username, string channelName)
|
||||||
@@ -214,7 +217,6 @@ public class ChatService : IChatService
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Content = dbContent,
|
Content = dbContent,
|
||||||
Type = MessageType.Text,
|
|
||||||
SentAt = DateTimeOffset.UtcNow,
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
ChannelId = channel.Id,
|
ChannelId = channel.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
@@ -233,9 +235,6 @@ public class ChatService : IChatService
|
|||||||
message.SenderUsername,
|
message.SenderUsername,
|
||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
MessageType.Text,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
message.SentAt,
|
message.SentAt,
|
||||||
Embeds: embeds);
|
Embeds: embeds);
|
||||||
|
|
||||||
@@ -245,15 +244,16 @@ public class ChatService : IChatService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count)
|
public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
||||||
|
offset = Math.Max(offset, 0);
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
return await GetChannelHistoryInternalAsync(db, channelName, count);
|
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
|
public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
|
||||||
@@ -366,7 +366,7 @@ public class ChatService : IChatService
|
|||||||
return string.Join('\n', result);
|
return string.Join('\n', result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
|
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count, int offset = 0)
|
||||||
{
|
{
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
@@ -375,6 +375,7 @@ public class ChatService : IChatService
|
|||||||
var raw = await db.Messages
|
var raw = await db.Messages
|
||||||
.Where(m => m.ChannelId == channel.Id)
|
.Where(m => m.ChannelId == channel.Id)
|
||||||
.OrderByDescending(m => m.SentAt)
|
.OrderByDescending(m => m.SentAt)
|
||||||
|
.Skip(offset)
|
||||||
.Take(count)
|
.Take(count)
|
||||||
.Join(db.Users,
|
.Join(db.Users,
|
||||||
m => m.SenderUserId,
|
m => m.SenderUserId,
|
||||||
@@ -384,12 +385,53 @@ public class ChatService : IChatService
|
|||||||
|
|
||||||
raw.Reverse();
|
raw.Reverse();
|
||||||
|
|
||||||
return raw.Select(x =>
|
var messageIds = raw.Select(x => x.m.Id).ToList();
|
||||||
|
var attachmentsByMessage = (await db.Attachments
|
||||||
|
.Where(a => messageIds.Contains(a.MessageId))
|
||||||
|
.ToListAsync())
|
||||||
|
.GroupBy(a => a.MessageId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// Attachment blobs can be pruned by retention while the message rows remain. Check what's
|
||||||
|
// actually on disk (one scan) so we never render a dead download, and so we can drop
|
||||||
|
// attachment-only messages whose files are all gone.
|
||||||
|
var storedFileIds = attachmentsByMessage.Count > 0 ? _fileStorage.GetStoredFileIds() : [];
|
||||||
|
|
||||||
|
var result = new List<MessageDto>(raw.Count);
|
||||||
|
var deadMessageIds = new List<Guid>();
|
||||||
|
|
||||||
|
foreach (var x in raw)
|
||||||
{
|
{
|
||||||
// Decrypt DB content (handles both encrypted and plaintext via prefix detection)
|
// Decrypt DB content (handles both encrypted and plaintext via prefix detection)
|
||||||
var plaintext = _encryption.Decrypt(x.m.Content);
|
var plaintext = _encryption.Decrypt(x.m.Content);
|
||||||
var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson);
|
|
||||||
|
|
||||||
|
List<AttachmentDto>? attachments = null;
|
||||||
|
var hadAttachments = attachmentsByMessage.TryGetValue(x.m.Id, out var atts) && atts.Count > 0;
|
||||||
|
if (hadAttachments)
|
||||||
|
{
|
||||||
|
// Keep only attachments whose underlying file still exists on disk.
|
||||||
|
var live = atts!.Where(a => storedFileIds.Contains(FileIdFromUrl(a.Url))).ToList();
|
||||||
|
|
||||||
|
// Attachment-only message whose files are all gone → prune it entirely.
|
||||||
|
if (live.Count == 0 && string.IsNullOrEmpty(plaintext))
|
||||||
|
{
|
||||||
|
deadMessageIds.Add(x.m.Id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (live.Count > 0)
|
||||||
|
{
|
||||||
|
attachments = live.Select(a => new AttachmentDto(
|
||||||
|
a.Kind,
|
||||||
|
a.Url,
|
||||||
|
a.FileName,
|
||||||
|
a.FileSize,
|
||||||
|
// Preview re-encrypted for transport; client decrypts (and room-decrypts for E2E)
|
||||||
|
_encryption.EncryptNullable(_encryption.DecryptNullable(a.AsciiPreview)))).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson);
|
||||||
List<EmbedDto>? embeds = null;
|
List<EmbedDto>? embeds = null;
|
||||||
if (embedJsonPlain is not null)
|
if (embedJsonPlain is not null)
|
||||||
{
|
{
|
||||||
@@ -398,18 +440,36 @@ public class ChatService : IChatService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt for transport — client decrypts
|
// Encrypt for transport — client decrypts
|
||||||
return new MessageDto(
|
result.Add(new MessageDto(
|
||||||
x.m.Id,
|
x.m.Id,
|
||||||
_encryption.Encrypt(plaintext),
|
_encryption.Encrypt(plaintext),
|
||||||
x.m.SenderUsername,
|
x.m.SenderUsername,
|
||||||
x.NicknameColor,
|
x.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
x.m.Type,
|
|
||||||
x.m.AttachmentUrl,
|
|
||||||
x.m.AttachmentFileName,
|
|
||||||
x.m.SentAt,
|
x.m.SentAt,
|
||||||
x.m.AttachmentFileSize,
|
attachments,
|
||||||
embeds);
|
embeds));
|
||||||
}).ToList();
|
}
|
||||||
|
|
||||||
|
// Lazily delete the pruned messages (+ their attachment rows) as they're encountered.
|
||||||
|
if (deadMessageIds.Count > 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.Attachments.Where(a => deadMessageIds.Contains(a.MessageId)).ExecuteDeleteAsync();
|
||||||
|
await db.Messages.Where(m => deadMessageIds.Contains(m.Id)).ExecuteDeleteAsync();
|
||||||
|
_logger.LogInformation("Pruned {Count} attachment-only messages with missing files in '{Channel}'",
|
||||||
|
deadMessageIds.Count, channelName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to prune messages with missing attachments in '{Channel}'", channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Extracts the storage file id from an attachment URL (e.g. "/api/files/{id}").</summary>
|
||||||
|
private static string FileIdFromUrl(string url) => url.Split('/')[^1];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists and exposes the EchoHubSpace directory claim — the opaque token issued on first
|
||||||
|
/// registration and the row's stable <c>ServerId</c>. Also surfaces ephemeral registration
|
||||||
|
/// status (success/failure code, conflicting hosts) for operator-facing endpoints.
|
||||||
|
///
|
||||||
|
/// Persistence uses atomic write (tmp + rename). Treat the file contents as a secret.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DirectoryClaimStore
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly ILogger<DirectoryClaimStore> _logger;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
|
||||||
|
private PersistedClaim _persisted = new(null, null);
|
||||||
|
private RegistrationStatus _status = new(false, null, null, null, null);
|
||||||
|
|
||||||
|
public DirectoryClaimStore(IConfiguration configuration, ILogger<DirectoryClaimStore> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_filePath = ResolveFilePath(configuration);
|
||||||
|
Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string FilePath => _filePath;
|
||||||
|
|
||||||
|
public string? ClaimToken => Volatile.Read(ref _persisted).ClaimToken;
|
||||||
|
public Guid? ServerId => Volatile.Read(ref _persisted).ServerId;
|
||||||
|
|
||||||
|
public RegistrationStatus Status => Volatile.Read(ref _status);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persist a freshly-issued claim token alongside the server's stable ServerId.
|
||||||
|
/// Called exactly once per row's lifetime — on first claim. Atomic on-disk swap.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SaveClaimAsync(string claimToken, Guid serverId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var next = new PersistedClaim(claimToken, serverId);
|
||||||
|
await WriteAtomicAsync(next, ct);
|
||||||
|
Volatile.Write(ref _persisted, next);
|
||||||
|
_logger.LogInformation("Persisted directory claim token for ServerId {ServerId} at {Path}", serverId, _filePath);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_writeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update only the ServerId — used when re-registering with an existing token (Success path,
|
||||||
|
/// hub returns ServerId again but no fresh token). No-op if the value is unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public async Task UpdateServerIdAsync(Guid serverId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var current = Volatile.Read(ref _persisted);
|
||||||
|
if (current.ServerId == serverId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _writeLock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var next = current with { ServerId = serverId };
|
||||||
|
await WriteAtomicAsync(next, ct);
|
||||||
|
Volatile.Write(ref _persisted, next);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_writeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetSuccess(Guid serverId)
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _status, new RegistrationStatus(
|
||||||
|
IsRegistered: true,
|
||||||
|
ServerId: serverId,
|
||||||
|
LastRegisteredAt: DateTimeOffset.UtcNow,
|
||||||
|
LastError: null,
|
||||||
|
ConflictingHosts: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetFailure(string errorCode, string[]? conflictingHosts)
|
||||||
|
{
|
||||||
|
var current = Volatile.Read(ref _status);
|
||||||
|
Volatile.Write(ref _status, current with
|
||||||
|
{
|
||||||
|
IsRegistered = false,
|
||||||
|
LastError = errorCode,
|
||||||
|
ConflictingHosts = conflictingHosts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(_filePath);
|
||||||
|
var loaded = JsonSerializer.Deserialize<PersistedClaim>(stream, JsonOptions);
|
||||||
|
if (loaded is not null)
|
||||||
|
{
|
||||||
|
_persisted = loaded;
|
||||||
|
_logger.LogInformation("Loaded directory claim from {Path} (ServerId {ServerId})", _filePath, loaded.ServerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Don't crash startup over a corrupt state file — log and proceed as if no claim exists.
|
||||||
|
// Operator will see HostAlreadyClaimed on next register and can intervene.
|
||||||
|
_logger.LogError(ex, "Failed to read directory claim file at {Path} — treating as unclaimed", _filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteAtomicAsync(PersistedClaim claim, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(_filePath);
|
||||||
|
if (!string.IsNullOrEmpty(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
var tmpPath = _filePath + ".tmp";
|
||||||
|
|
||||||
|
await using (var stream = new FileStream(
|
||||||
|
tmpPath,
|
||||||
|
FileMode.Create,
|
||||||
|
FileAccess.Write,
|
||||||
|
FileShare.None,
|
||||||
|
bufferSize: 4096,
|
||||||
|
useAsync: true))
|
||||||
|
{
|
||||||
|
await JsonSerializer.SerializeAsync(stream, claim, JsonOptions, ct);
|
||||||
|
await stream.FlushAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0600 on Unix — the file holds a secret. No-op on Windows.
|
||||||
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.SetUnixFileMode(tmpPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to set restrictive permissions on {Path}", tmpPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(tmpPath, _filePath, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveFilePath(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var configured = configuration["Server:DirectoryClaimPath"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(configured))
|
||||||
|
return configured;
|
||||||
|
|
||||||
|
// Co-locate with the SQLite database so a single data-directory backup captures both.
|
||||||
|
var connectionString = configuration.GetConnectionString("DefaultConnection");
|
||||||
|
if (!string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var builder = new SqliteConnectionStringBuilder(connectionString);
|
||||||
|
if (!string.IsNullOrWhiteSpace(builder.DataSource))
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(Path.GetFullPath(builder.DataSource));
|
||||||
|
if (!string.IsNullOrWhiteSpace(dir))
|
||||||
|
return Path.Combine(dir, "directory-claim.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fall through to default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, "directory-claim.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PersistedClaim(string? ClaimToken, Guid? ServerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RegistrationStatus(
|
||||||
|
bool IsRegistered,
|
||||||
|
Guid? ServerId,
|
||||||
|
DateTimeOffset? LastRegisteredAt,
|
||||||
|
string? LastError,
|
||||||
|
string[]? ConflictingHosts);
|
||||||
@@ -35,6 +35,22 @@ public class FileStorageService
|
|||||||
return files.Length > 0 ? files[0] : null;
|
return files.Length > 0 ? files[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the set of stored file ids (filenames without extension) currently on disk.
|
||||||
|
/// One directory scan, so callers can bulk-check many attachments without a glob per file.
|
||||||
|
/// </summary>
|
||||||
|
public HashSet<string> GetStoredFileIds()
|
||||||
|
{
|
||||||
|
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (!Directory.Exists(_storagePath))
|
||||||
|
return ids;
|
||||||
|
|
||||||
|
foreach (var file in Directory.EnumerateFiles(_storagePath))
|
||||||
|
ids.Add(Path.GetFileNameWithoutExtension(file));
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
public void DeleteFile(string fileId)
|
public void DeleteFile(string fileId)
|
||||||
{
|
{
|
||||||
var filePath = GetFilePath(fileId);
|
var filePath = GetFilePath(fileId);
|
||||||
|
|||||||
@@ -10,10 +10,18 @@ public class PresenceTracker
|
|||||||
|
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the distinct online user count changes (multi-connection users only fire once).
|
||||||
|
/// </summary>
|
||||||
|
public event Action<int>? UserCountChanged;
|
||||||
|
|
||||||
public void UserConnected(string connectionId, Guid userId, string username)
|
public void UserConnected(string connectionId, Guid userId, string username)
|
||||||
{
|
{
|
||||||
_connections[connectionId] = (userId, username);
|
_connections[connectionId] = (userId, username);
|
||||||
|
|
||||||
|
bool userIsNew;
|
||||||
|
int newCount;
|
||||||
|
|
||||||
// Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside.
|
// Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside.
|
||||||
// It also makes the TryGetValue → add sequence atomic to prevent race conditions.
|
// It also makes the TryGetValue → add sequence atomic to prevent race conditions.
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -22,10 +30,19 @@ public class PresenceTracker
|
|||||||
{
|
{
|
||||||
connections = new HashSet<string>();
|
connections = new HashSet<string>();
|
||||||
_userConnections[username] = connections;
|
_userConnections[username] = connections;
|
||||||
|
userIsNew = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
userIsNew = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
connections.Add(connectionId);
|
connections.Add(connectionId);
|
||||||
|
newCount = _userConnections.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userIsNew)
|
||||||
|
UserCountChanged?.Invoke(newCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? UserDisconnected(string connectionId)
|
public string? UserDisconnected(string connectionId)
|
||||||
@@ -34,6 +51,8 @@ public class PresenceTracker
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
var username = userInfo.username;
|
var username = userInfo.username;
|
||||||
|
bool userRemoved = false;
|
||||||
|
int newCount;
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
@@ -45,10 +64,16 @@ public class PresenceTracker
|
|||||||
{
|
{
|
||||||
_userConnections.TryRemove(username, out _);
|
_userConnections.TryRemove(username, out _);
|
||||||
_userChannels.TryRemove(username, out _);
|
_userChannels.TryRemove(username, out _);
|
||||||
|
userRemoved = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newCount = _userConnections.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userRemoved)
|
||||||
|
UserCountChanged?.Invoke(newCount);
|
||||||
|
|
||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,20 +185,29 @@ public class PresenceTracker
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public (List<string> ConnectionIds, List<string> Channels) ForceRemoveUser(string username)
|
public (List<string> ConnectionIds, List<string> Channels) ForceRemoveUser(string username)
|
||||||
{
|
{
|
||||||
|
bool userRemoved;
|
||||||
|
int newCount;
|
||||||
|
List<string> channels;
|
||||||
|
List<string> connectionIds;
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var channels = _userChannels.TryRemove(username, out var ch)
|
channels = _userChannels.TryRemove(username, out var ch)
|
||||||
? ch.ToList()
|
? ch.ToList()
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
var connectionIds = _userConnections.TryRemove(username, out var conns)
|
userRemoved = _userConnections.TryRemove(username, out var conns);
|
||||||
? conns.ToList()
|
connectionIds = userRemoved ? conns!.ToList() : [];
|
||||||
: [];
|
|
||||||
|
|
||||||
foreach (var connId in connectionIds)
|
foreach (var connId in connectionIds)
|
||||||
_connections.TryRemove(connId, out _);
|
_connections.TryRemove(connId, out _);
|
||||||
|
|
||||||
return (connectionIds, channels);
|
newCount = _userConnections.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userRemoved)
|
||||||
|
UserCountChanged?.Invoke(newCount);
|
||||||
|
|
||||||
|
return (connectionIds, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Channels;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
|
||||||
namespace EchoHub.Server.Services;
|
namespace EchoHub.Server.Services;
|
||||||
@@ -5,24 +8,36 @@ namespace EchoHub.Server.Services;
|
|||||||
public sealed class ServerDirectoryService : BackgroundService
|
public sealed class ServerDirectoryService : BackgroundService
|
||||||
{
|
{
|
||||||
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
|
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
|
||||||
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
|
|
||||||
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2);
|
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2);
|
||||||
private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30);
|
private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30);
|
||||||
|
private static readonly TimeSpan UserCountMinInterval = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly PresenceTracker _presenceTracker;
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly DirectoryClaimStore _claimStore;
|
||||||
private readonly ILogger<ServerDirectoryService> _logger;
|
private readonly ILogger<ServerDirectoryService> _logger;
|
||||||
|
|
||||||
|
// Single-slot, latest-wins channel coalesces bursts of presence changes into one update.
|
||||||
|
private readonly Channel<int> _userCountUpdates = Channel.CreateBounded<int>(
|
||||||
|
new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
|
||||||
|
|
||||||
private HubConnection? _connection;
|
private HubConnection? _connection;
|
||||||
private int _lastReportedUserCount = -1;
|
private int _lastReportedUserCount = -1;
|
||||||
|
|
||||||
|
// Set true when a registration error code arrives (HostAlreadyClaimed/InvalidToken/HostConflict).
|
||||||
|
// Once set, we stop attempting register on this connection AND on any reconnects, since the
|
||||||
|
// hub won't kick us off and we'd otherwise tight-loop. Operator must restart after fixing config.
|
||||||
|
private bool _registrationPermanentlyFailed;
|
||||||
|
|
||||||
public ServerDirectoryService(
|
public ServerDirectoryService(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
|
DirectoryClaimStore claimStore,
|
||||||
ILogger<ServerDirectoryService> logger)
|
ILogger<ServerDirectoryService> logger)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
|
_claimStore = claimStore;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,19 +53,44 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var host = _configuration["Server:PublicHost"];
|
var hosts = _configuration.GetSection("Server:PublicHosts").Get<string[]>()
|
||||||
|
?.Where(h => !string.IsNullOrWhiteSpace(h))
|
||||||
|
.ToArray() ?? Array.Empty<string>();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(host))
|
if (hosts.Length == 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration");
|
_logger.LogWarning("PublicServer is enabled but Server:PublicHosts is empty — skipping directory registration");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var serverName = _configuration["Server:Name"] ?? "EchoHub Server";
|
var serverName = _configuration["Server:Name"] ?? "EchoHub Server";
|
||||||
var description = _configuration["Server:Description"];
|
var description = _configuration["Server:Description"];
|
||||||
|
var tags = _configuration.GetSection("Server:Tags").Get<string[]>()
|
||||||
|
?.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||||
|
.ToArray() ?? Array.Empty<string>();
|
||||||
|
var version = ResolveVersion();
|
||||||
|
|
||||||
_logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host);
|
_logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Hosts})", serverName, string.Join(", ", hosts));
|
||||||
|
|
||||||
|
_presenceTracker.UserCountChanged += OnUserCountChanged;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RunConnectionLoopAsync(serverName, description, hosts, version, tags, stoppingToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_presenceTracker.UserCountChanged -= OnUserCountChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunConnectionLoopAsync(
|
||||||
|
string serverName,
|
||||||
|
string? description,
|
||||||
|
string[] hosts,
|
||||||
|
string version,
|
||||||
|
string[] tags,
|
||||||
|
CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
// Outer loop: rebuilds the connection if automatic reconnect permanently fails
|
// Outer loop: rebuilds the connection if automatic reconnect permanently fails
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -76,9 +116,15 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
|
|
||||||
connection.Reconnected += async _ =>
|
connection.Reconnected += async _ =>
|
||||||
{
|
{
|
||||||
|
if (_registrationPermanentlyFailed)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Reconnected to directory but previous registration permanently failed — not re-registering. Restart the server after fixing configuration.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Reconnected to directory — re-registering server");
|
_logger.LogInformation("Reconnected to directory — re-registering server");
|
||||||
_lastReportedUserCount = -1;
|
_lastReportedUserCount = -1;
|
||||||
await RegisterAsync(serverName, description, host);
|
await RegisterAsync(serverName, description, hosts, version, tags);
|
||||||
};
|
};
|
||||||
|
|
||||||
connection.Closed += ex =>
|
connection.Closed += ex =>
|
||||||
@@ -97,10 +143,10 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
_logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
|
_logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
|
||||||
await RegisterAsync(serverName, description, host);
|
await RegisterAsync(serverName, description, hosts, version, tags);
|
||||||
|
|
||||||
// Poll user count until the connection is permanently closed or cancellation
|
// Push user-count updates as PresenceTracker raises events, until the connection closes or cancellation
|
||||||
await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
|
await ProcessUserCountUpdatesAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
|
||||||
|
|
||||||
if (stoppingToken.IsCancellationRequested)
|
if (stoppingToken.IsCancellationRequested)
|
||||||
return;
|
return;
|
||||||
@@ -151,33 +197,62 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PollUserCountAsync(HubConnection connection, Task connectionClosed, CancellationToken ct)
|
private void OnUserCountChanged(int newCount)
|
||||||
{
|
{
|
||||||
|
// Single-slot channel: latest write wins, so a burst of presence changes coalesces.
|
||||||
|
_userCountUpdates.Writer.TryWrite(newCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessUserCountUpdatesAsync(HubConnection connection, Task connectionClosed, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var lastSentAt = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var delayTask = Task.Delay(UpdateInterval, ct);
|
var waitTask = _userCountUpdates.Reader.WaitToReadAsync(ct).AsTask();
|
||||||
var completed = await Task.WhenAny(delayTask, connectionClosed);
|
var completed = await Task.WhenAny(waitTask, connectionClosed);
|
||||||
|
|
||||||
if (completed == connectionClosed)
|
if (completed == connectionClosed)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Observe the delay task (may throw if cancelled)
|
bool hasUpdate;
|
||||||
try { await delayTask; }
|
try { hasUpdate = await waitTask; }
|
||||||
catch (OperationCanceledException) { return; }
|
catch (OperationCanceledException) { return; }
|
||||||
|
|
||||||
|
if (!hasUpdate)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_userCountUpdates.Reader.TryRead(out var count))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Throttle: enforce a minimum interval between sends. While we wait, drain newer
|
||||||
|
// values so the eventual send carries the latest count, not a stale snapshot.
|
||||||
|
var elapsed = DateTimeOffset.UtcNow - lastSentAt;
|
||||||
|
if (elapsed < UserCountMinInterval)
|
||||||
|
{
|
||||||
|
try { await Task.Delay(UserCountMinInterval - elapsed, ct); }
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
|
||||||
|
while (_userCountUpdates.Reader.TryRead(out var newer))
|
||||||
|
count = newer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count == _lastReportedUserCount)
|
||||||
|
continue;
|
||||||
|
|
||||||
if (connection.State != HubConnectionState.Connected)
|
if (connection.State != HubConnectionState.Connected)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var currentCount = _presenceTracker.GetOnlineUserCount();
|
// No point pushing presence to a row we don't own (or never claimed)
|
||||||
|
if (_registrationPermanentlyFailed || !_claimStore.Status.IsRegistered)
|
||||||
if (currentCount == _lastReportedUserCount)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
|
await connection.InvokeAsync("UpdateUserCount", count, ct);
|
||||||
_lastReportedUserCount = currentCount;
|
_lastReportedUserCount = count;
|
||||||
_logger.LogDebug("Updated directory user count to {Count}", currentCount);
|
lastSentAt = DateTimeOffset.UtcNow;
|
||||||
|
_logger.LogDebug("Updated directory user count to {Count}", count);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -192,18 +267,22 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
return delay > ReconnectMaxDelay ? ReconnectMaxDelay : delay;
|
return delay > ReconnectMaxDelay ? ReconnectMaxDelay : delay;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RegisterAsync(string name, string? description, string host)
|
private async Task RegisterAsync(string name, string? description, string[] hosts, string version, string[] tags)
|
||||||
{
|
{
|
||||||
if (_connection?.State != HubConnectionState.Connected)
|
if (_connection?.State != HubConnectionState.Connected)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (_registrationPermanentlyFailed)
|
||||||
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userCount = _presenceTracker.GetOnlineUserCount();
|
var userCount = _presenceTracker.GetOnlineUserCount();
|
||||||
var dto = new RegisterServerDto(name, description, host, userCount);
|
// ClaimToken is null on first-ever registration; otherwise the token persisted on first claim.
|
||||||
await _connection.InvokeAsync("RegisterServer", dto);
|
var dto = new RegisterServerDto(name, description, hosts, userCount, version, tags, _claimStore.ClaimToken);
|
||||||
_lastReportedUserCount = userCount;
|
|
||||||
_logger.LogInformation("Registered with directory as {Name} at {Host}", name, host);
|
var envelope = await _connection.InvokeAsync<Response<RegisterServerResult>>("RegisterServer", dto);
|
||||||
|
await HandleRegistrationResponseAsync(envelope, userCount, name, hosts);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -211,6 +290,153 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandleRegistrationResponseAsync(Response<RegisterServerResult>? envelope, int userCount, string name, string[] hosts)
|
||||||
|
{
|
||||||
|
if (envelope is null)
|
||||||
|
{
|
||||||
|
_registrationPermanentlyFailed = true;
|
||||||
|
_logger.LogError("Directory returned a null envelope for RegisterServer — treating as malformed. Server will not retry until restarted.");
|
||||||
|
_claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin protocol version. Spec: fail hard on mismatch — bumps are coordinated.
|
||||||
|
if (!string.Equals(envelope.Version, DirectoryProtocol.Version, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_registrationPermanentlyFailed = true;
|
||||||
|
_logger.LogError(
|
||||||
|
"Directory protocol version mismatch: client expects {Expected}, hub returned {Actual}. " +
|
||||||
|
"Refusing to operate. Coordinate a deploy that aligns both sides.",
|
||||||
|
DirectoryProtocol.Version, envelope.Version ?? "(null)");
|
||||||
|
_claimStore.SetFailure(DirectoryRegistrationErrors.ProtocolVersionMismatch, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!envelope.IsSuccess)
|
||||||
|
{
|
||||||
|
await HandleRegistrationErrorAsync(envelope.Errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.Data is null)
|
||||||
|
{
|
||||||
|
_registrationPermanentlyFailed = true;
|
||||||
|
_logger.LogError("Directory returned IsSuccess=true but Data was null — treating as malformed. Server will not retry until restarted.");
|
||||||
|
_claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = envelope.Data;
|
||||||
|
var serverId = data.ServerId;
|
||||||
|
|
||||||
|
// Persist a freshly-issued claim token *before* anything else acks success — durability guarantee for first claim.
|
||||||
|
if (!string.IsNullOrEmpty(data.ClaimToken))
|
||||||
|
{
|
||||||
|
await _claimStore.SaveClaimAsync(data.ClaimToken, serverId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No fresh token (re-register): just keep the persisted ServerId in sync defensively.
|
||||||
|
await _claimStore.UpdateServerIdAsync(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_claimStore.SetSuccess(serverId);
|
||||||
|
_lastReportedUserCount = userCount;
|
||||||
|
_logger.LogInformation("Registered with directory as {Name} at {Hosts} (ServerId {ServerId})", name, string.Join(", ", hosts), serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleRegistrationErrorAsync(ErrorDetail[]? errors)
|
||||||
|
{
|
||||||
|
_registrationPermanentlyFailed = true;
|
||||||
|
|
||||||
|
var firstError = errors is { Length: > 0 } ? errors[0] : null;
|
||||||
|
var code = firstError?.Code ?? "UnknownError";
|
||||||
|
var conflictingHosts = ExtractConflictingHosts(firstError);
|
||||||
|
var conflicts = conflictingHosts is { Length: > 0 }
|
||||||
|
? string.Join(", ", conflictingHosts)
|
||||||
|
: "(none reported)";
|
||||||
|
|
||||||
|
switch (code)
|
||||||
|
{
|
||||||
|
case DirectoryRegistrationErrors.HostAlreadyClaimed:
|
||||||
|
_logger.LogError(
|
||||||
|
"Directory rejected registration: host(s) already claimed by another server: {ConflictingHosts}. " +
|
||||||
|
"Change Server:PublicHosts or contact the directory admin to release the claim. Server will not retry until restarted.",
|
||||||
|
conflicts);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DirectoryRegistrationErrors.InvalidToken:
|
||||||
|
_logger.LogError(
|
||||||
|
"Directory rejected registration: persisted claim token is invalid (likely deleted by admin or stale). " +
|
||||||
|
"Delete the claim file ({ClaimFile}) to claim fresh, or contact the directory admin. Server will not retry until restarted.",
|
||||||
|
_claimStore.FilePath);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DirectoryRegistrationErrors.HostConflict:
|
||||||
|
_logger.LogError(
|
||||||
|
"Directory rejected registration: token is valid but newly-advertised host(s) conflict with another server's row: {ConflictingHosts}. " +
|
||||||
|
"Remove the conflicting entries from Server:PublicHosts. Server will not retry until restarted.",
|
||||||
|
conflicts);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DirectoryRegistrationErrors.InvalidInput:
|
||||||
|
_logger.LogError(
|
||||||
|
"Directory rejected registration as InvalidInput ({Message}). Likely a client/hub contract drift — check Server config. Server will not retry until restarted.",
|
||||||
|
firstError?.Message ?? "(no message)");
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
_logger.LogError(
|
||||||
|
"Directory rejected registration with unknown error code: {Error} ({Message}). Server will not retry until restarted.",
|
||||||
|
code, firstError?.Message ?? "(no message)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
_claimStore.SetFailure(code, conflictingHosts);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pulls <c>ConflictingHosts</c> out of an error's loosely-typed <c>Data</c> payload.
|
||||||
|
/// Tolerates both PascalCase and camelCase keys since SignalR's wire casing depends on
|
||||||
|
/// the hub's serializer config and the field is typed <c>object?</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static string[]? ExtractConflictingHosts(ErrorDetail? error)
|
||||||
|
{
|
||||||
|
if (error?.Data is not JsonElement element || element.ValueKind != JsonValueKind.Object)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!element.TryGetProperty("ConflictingHosts", out var hostsProp)
|
||||||
|
&& !element.TryGetProperty("conflictingHosts", out hostsProp))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (hostsProp.ValueKind != JsonValueKind.Array)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
List<string> hosts = [];
|
||||||
|
foreach (var item in hostsProp.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s)
|
||||||
|
hosts.Add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hosts.Count == 0 ? null : hosts.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveVersion()
|
||||||
|
{
|
||||||
|
var assembly = typeof(ServerDirectoryService).Assembly;
|
||||||
|
var informational = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||||
|
if (!string.IsNullOrWhiteSpace(informational))
|
||||||
|
{
|
||||||
|
// Strip git SHA suffix that SourceLink appends (e.g. "0.2.10+abc123")
|
||||||
|
var plus = informational.IndexOf('+');
|
||||||
|
return plus >= 0 ? informational[..plus] : informational;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assembly.GetName().Version?.ToString() ?? "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task DisposeConnectionAsync(HubConnection connection)
|
private static async Task DisposeConnectionAsync(HubConnection connection)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -243,4 +469,44 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal record RegisterServerDto(string Name, string? Description, string Host, int UserCount);
|
internal record RegisterServerDto(
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
string[] Hosts,
|
||||||
|
int UserCount,
|
||||||
|
string Version,
|
||||||
|
string[] Tags,
|
||||||
|
string? ClaimToken);
|
||||||
|
|
||||||
|
internal record RegisterServerResult(Guid ServerId, string? ClaimToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Envelope wrapping every directory hub response. Mirrors the EchoHubSpace contract.
|
||||||
|
/// </summary>
|
||||||
|
internal record Response<T>(bool IsSuccess, T? Data, ErrorDetail[]? Errors, string? Version);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error entry inside a <see cref="Response{T}"/>. <c>Data</c> is loosely-typed because the
|
||||||
|
/// payload shape varies by error code (e.g. <c>{ ConflictingHosts: string[] }</c> for host errors).
|
||||||
|
/// </summary>
|
||||||
|
internal record ErrorDetail(string Code, string? Message, JsonElement? Data);
|
||||||
|
|
||||||
|
internal static class DirectoryProtocol
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pinned envelope protocol version. Bumps are coordinated across both repos.
|
||||||
|
/// </summary>
|
||||||
|
public const string Version = "1.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class DirectoryRegistrationErrors
|
||||||
|
{
|
||||||
|
public const string InvalidInput = "InvalidInput";
|
||||||
|
public const string InvalidToken = "InvalidToken";
|
||||||
|
public const string HostAlreadyClaimed = "HostAlreadyClaimed";
|
||||||
|
public const string HostConflict = "HostConflict";
|
||||||
|
|
||||||
|
// Client-side synthetic codes (never returned by hub, generated locally for status reporting)
|
||||||
|
public const string ProtocolVersionMismatch = "ProtocolVersionMismatch";
|
||||||
|
public const string MalformedResponse = "MalformedResponse";
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public static partial class DataMigrationService
|
|||||||
await EnsureDefaultChannelsPublicAsync(db, logger);
|
await EnsureDefaultChannelsPublicAsync(db, logger);
|
||||||
await MigrateAnsiMessagesAsync(db, logger);
|
await MigrateAnsiMessagesAsync(db, logger);
|
||||||
await MigrateEmbedJsonToArrayAsync(db, logger);
|
await MigrateEmbedJsonToArrayAsync(db, logger);
|
||||||
|
await MigrateLegacyAttachmentsAsync(db, logger);
|
||||||
await EnsureConfiguredAdminsAsync(db, config, logger);
|
await EnsureConfiguredAdminsAsync(db, config, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +98,59 @@ public static partial class DataMigrationService
|
|||||||
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
||||||
private static partial Regex AnsiColorRegex();
|
private static partial Regex AnsiColorRegex();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fold legacy single-attachment messages (which stored the file on the message row and,
|
||||||
|
/// for images, the ASCII art in Content) into the new Attachments model. Idempotent:
|
||||||
|
/// only migrates messages that still have a legacy AttachmentUrl and no Attachment rows.
|
||||||
|
/// After migrating, Content becomes empty (the ASCII art moves to the attachment preview)
|
||||||
|
/// and the legacy columns are nulled out.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task MigrateLegacyAttachmentsAsync(EchoHubDbContext db, ILogger logger)
|
||||||
|
{
|
||||||
|
var legacy = await db.Messages
|
||||||
|
.Where(m => m.AttachmentUrl != null && m.Attachments.Count == 0)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (legacy.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
logger.LogInformation("Migrating {Count} legacy single-attachment messages to the attachments model...", legacy.Count);
|
||||||
|
|
||||||
|
foreach (var message in legacy)
|
||||||
|
{
|
||||||
|
var kind = message.Type switch
|
||||||
|
{
|
||||||
|
Core.Models.MessageType.Image => AttachmentKind.Image,
|
||||||
|
Core.Models.MessageType.Audio => AttachmentKind.Audio,
|
||||||
|
_ => AttachmentKind.File,
|
||||||
|
};
|
||||||
|
|
||||||
|
// For images the ASCII art lived in Content; for audio/file Content was just the
|
||||||
|
// filename (now redundant with the attachment). Either way the caption becomes empty.
|
||||||
|
var preview = kind == AttachmentKind.Image ? message.Content : null;
|
||||||
|
|
||||||
|
db.Attachments.Add(new Attachment
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
MessageId = message.Id,
|
||||||
|
Kind = kind,
|
||||||
|
Url = message.AttachmentUrl!,
|
||||||
|
FileName = message.AttachmentFileName ?? "file",
|
||||||
|
FileSize = message.AttachmentFileSize ?? 0,
|
||||||
|
AsciiPreview = preview,
|
||||||
|
});
|
||||||
|
|
||||||
|
message.Content = string.Empty;
|
||||||
|
message.AttachmentUrl = null;
|
||||||
|
message.AttachmentFileName = null;
|
||||||
|
message.AttachmentFileSize = null;
|
||||||
|
message.Type = Core.Models.MessageType.Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Migrated {Count} legacy attachments.", legacy.Count);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensure usernames listed in Server:Admins config are at least Admin role.
|
/// Ensure usernames listed in Server:Admins config are at least Admin role.
|
||||||
/// Acts as a safety net in case the first registered user didn't get Owner role.
|
/// Acts as a safety net in case the first registered user didn't get Owner role.
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"Name": "My EchoHub Server",
|
"Name": "My EchoHub Server",
|
||||||
"Description": "A self-hosted EchoHub chat server",
|
"Description": "A self-hosted EchoHub chat server",
|
||||||
"PublicServer": false,
|
"PublicServer": false,
|
||||||
"PublicHost": "",
|
"PublicHosts": [],
|
||||||
|
"Tags": [],
|
||||||
"Admins": []
|
"Admins": []
|
||||||
},
|
},
|
||||||
"Storage": {
|
"Storage": {
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ public class CommandHandlerTests
|
|||||||
{
|
{
|
||||||
var handler = CreateHandler();
|
var handler = CreateHandler();
|
||||||
string? capturedChannel = null;
|
string? capturedChannel = null;
|
||||||
handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; };
|
handler.OnJoinChannel += (ch, _) => { capturedChannel = ch; return Task.CompletedTask; };
|
||||||
|
|
||||||
await handler.HandleAsync("/join #random");
|
await handler.HandleAsync("/join #random");
|
||||||
Assert.Equal("random", capturedChannel);
|
Assert.Equal("random", capturedChannel);
|
||||||
@@ -204,12 +204,25 @@ public class CommandHandlerTests
|
|||||||
{
|
{
|
||||||
var handler = CreateHandler();
|
var handler = CreateHandler();
|
||||||
string? capturedChannel = null;
|
string? capturedChannel = null;
|
||||||
handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; };
|
handler.OnJoinChannel += (ch, _) => { capturedChannel = ch; return Task.CompletedTask; };
|
||||||
|
|
||||||
await handler.HandleAsync("/join random");
|
await handler.HandleAsync("/join random");
|
||||||
Assert.Equal("random", capturedChannel);
|
Assert.Equal("random", capturedChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Join_WithPassword_PassesPassword()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedChannel = null;
|
||||||
|
string? capturedPassword = null;
|
||||||
|
handler.OnJoinChannel += (ch, pw) => { capturedChannel = ch; capturedPassword = pw; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
await handler.HandleAsync("/join #secret hunter2");
|
||||||
|
Assert.Equal("secret", capturedChannel);
|
||||||
|
Assert.Equal("hunter2", capturedPassword);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HandleAsync_Join_NoArgs_ReturnsError()
|
public async Task HandleAsync_Join_NoArgs_ReturnsError()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using EchoHub.Client.UI.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class DroppedFileParserTests
|
||||||
|
{
|
||||||
|
// ── LooksLikePath ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("C:\\Users\\me\\cat.png")]
|
||||||
|
[InlineData("D:/photos/pic.jpg")]
|
||||||
|
[InlineData("\"C:\\My Files\\a b.png\"")]
|
||||||
|
[InlineData("/home/me/song.mp3")]
|
||||||
|
[InlineData("\\\\server\\share\\file.txt")]
|
||||||
|
public void LooksLikePath_PathLikeInput_ReturnsTrue(string text)
|
||||||
|
{
|
||||||
|
Assert.True(DroppedFileParser.LooksLikePath(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("hello world")]
|
||||||
|
[InlineData("check out my cat")]
|
||||||
|
[InlineData("no")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("@someone hi")]
|
||||||
|
public void LooksLikePath_NormalChat_ReturnsFalse(string text)
|
||||||
|
{
|
||||||
|
Assert.False(DroppedFileParser.LooksLikePath(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TryGetFiles (injected existence check) ────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetFiles_SingleAbsolutePath_Detected()
|
||||||
|
{
|
||||||
|
var path = Abs("Users", "me", "cat.png");
|
||||||
|
Assert.True(DroppedFileParser.TryGetFiles(path, out var files, Exists(path)));
|
||||||
|
Assert.Equal([path], files);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetFiles_QuotedPathWithSpaces_StripsQuotes()
|
||||||
|
{
|
||||||
|
var path = Abs("My Files", "a b.png");
|
||||||
|
Assert.True(DroppedFileParser.TryGetFiles($"\"{path}\"", out var files, Exists(path)));
|
||||||
|
Assert.Equal([path], files);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetFiles_MultipleQuotedPaths_Detected()
|
||||||
|
{
|
||||||
|
var a = Abs("a.png");
|
||||||
|
var b = Abs("b.mp3");
|
||||||
|
Assert.True(DroppedFileParser.TryGetFiles($"\"{a}\" \"{b}\"", out var files, Exists(a, b)));
|
||||||
|
Assert.Equal([a, b], files);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetFiles_PosixAbsolutePath_Detected()
|
||||||
|
{
|
||||||
|
// Path.IsPathFullyQualified treats "/x" as fully qualified only on non-Windows;
|
||||||
|
// this asserts the parser defers that judgment to the platform.
|
||||||
|
var isPosix = !OperatingSystem.IsWindows();
|
||||||
|
var detected = DroppedFileParser.TryGetFiles("/home/me/song.mp3", out var files, Exists("/home/me/song.mp3"));
|
||||||
|
Assert.Equal(isPosix, detected);
|
||||||
|
if (isPosix)
|
||||||
|
Assert.Equal(["/home/me/song.mp3"], files);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetFiles_NonExistentPath_ReturnsFalse()
|
||||||
|
{
|
||||||
|
Assert.False(DroppedFileParser.TryGetFiles(Abs("nope", "missing.png"), out _, _ => false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetFiles_PartialPathDuringTyping_ReturnsFalseUntilComplete()
|
||||||
|
{
|
||||||
|
// Only the fully typed path exists; prefixes do not.
|
||||||
|
var full = Abs("Users", "me", "cat.png");
|
||||||
|
var partial = Abs("Users", "me", "ca");
|
||||||
|
var exists = Exists(full);
|
||||||
|
Assert.False(DroppedFileParser.TryGetFiles(partial, out _, exists));
|
||||||
|
Assert.True(DroppedFileParser.TryGetFiles(full, out _, exists));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetFiles_OneMissingAmongMultiple_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var a = Abs("a.png");
|
||||||
|
var gone = Abs("gone.png");
|
||||||
|
Assert.False(DroppedFileParser.TryGetFiles($"\"{a}\" \"{gone}\"", out _, Exists(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetFiles_RealTempFile_DetectedWithDefaultExists()
|
||||||
|
{
|
||||||
|
var temp = Path.Combine(Path.GetTempPath(), $"echohub_drop_{Guid.NewGuid():N}.txt");
|
||||||
|
File.WriteAllText(temp, "x");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(DroppedFileParser.TryGetFiles(temp, out var files));
|
||||||
|
Assert.Single(files);
|
||||||
|
Assert.Equal(temp, files[0]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Func<string, bool> Exists(params string[] existing)
|
||||||
|
{
|
||||||
|
var set = new HashSet<string>(existing, StringComparer.OrdinalIgnoreCase);
|
||||||
|
return set.Contains;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an absolute path that <see cref="Path.IsPathFullyQualified"/> accepts on the current
|
||||||
|
/// OS — <c>C:\a\b</c> on Windows, <c>/a/b</c> elsewhere — so these tests run on any platform (CI is Linux).
|
||||||
|
/// </summary>
|
||||||
|
private static string Abs(params string[] segments) =>
|
||||||
|
OperatingSystem.IsWindows()
|
||||||
|
? "C:\\" + string.Join('\\', segments)
|
||||||
|
: "/" + string.Join('/', segments);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using EchoHub.Server.Services;
|
using EchoHub.Core.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace EchoHub.Tests;
|
namespace EchoHub.Tests;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Services;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,7 @@ public class IrcBroadcasterTests
|
|||||||
|
|
||||||
var encryptedContent = _encryption.Encrypt("Hello world!");
|
var encryptedContent = _encryption.Encrypt("Hello world!");
|
||||||
var message = new MessageDto(
|
var message = new MessageDto(
|
||||||
Guid.NewGuid(), encryptedContent, "alice", null, "general",
|
Guid.NewGuid(), encryptedContent, "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
MessageType.Text, null, null, DateTimeOffset.UtcNow);
|
|
||||||
|
|
||||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
@@ -97,8 +96,7 @@ public class IrcBroadcasterTests
|
|||||||
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
var message = new MessageDto(
|
var message = new MessageDto(
|
||||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
|
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
MessageType.Text, null, null, DateTimeOffset.UtcNow);
|
|
||||||
|
|
||||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
@@ -116,8 +114,7 @@ public class IrcBroadcasterTests
|
|||||||
var (_, randomStream) = AddConnectionWithCapture("charlie", "random");
|
var (_, randomStream) = AddConnectionWithCapture("charlie", "random");
|
||||||
|
|
||||||
var message = new MessageDto(
|
var message = new MessageDto(
|
||||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
|
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
MessageType.Text, null, null, DateTimeOffset.UtcNow);
|
|
||||||
|
|
||||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,50 @@ public class IrcCommandHandlerTests
|
|||||||
Assert.Equal("general", _chatService.JoinedChannels[0].Channel);
|
Assert.Equal("general", _chatService.JoinedChannels[0].Channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Join_WithKey_PassesKeyToChatService()
|
||||||
|
{
|
||||||
|
_channelService.TopicResult = (null, true);
|
||||||
|
|
||||||
|
var lines = await RunAuthenticated(["JOIN #secret hunter2"]);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("JOIN #secret"));
|
||||||
|
Assert.Single(_chatService.JoinKeys);
|
||||||
|
Assert.Equal("hunter2", _chatService.JoinKeys[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Join_MultipleChannelsWithKeys_PairsKeysByPosition()
|
||||||
|
{
|
||||||
|
_channelService.TopicResult = (null, true);
|
||||||
|
|
||||||
|
await RunAuthenticated(["JOIN #chan-a,#chan-b key1,key2"]);
|
||||||
|
|
||||||
|
Assert.Equal(["key1", "key2"], _chatService.JoinKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Join_ProtectedChannelWithoutKey_GetsBadChannelKey()
|
||||||
|
{
|
||||||
|
_chatService.JoinError = "Channel 'secret' is password protected.";
|
||||||
|
_chatService.JoinPasswordRequired = true;
|
||||||
|
|
||||||
|
var lines = await RunAuthenticated(["JOIN #secret"]);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("475") && l.Contains("#secret") && l.Contains("+k"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Join_EncryptedChannel_IsBlockedOverIrc()
|
||||||
|
{
|
||||||
|
_channelService.CryptoToReturn = new ChannelCryptoDto(true, "c2FsdA==");
|
||||||
|
|
||||||
|
var lines = await RunAuthenticated(["JOIN #vault"]);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("475") && l.Contains("#vault") && l.Contains("end-to-end encrypted"));
|
||||||
|
Assert.Empty(_chatService.JoinedChannels);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Join_SendsTopic()
|
public async Task Join_SendsTopic()
|
||||||
{
|
{
|
||||||
@@ -296,8 +340,7 @@ public class IrcCommandHandlerTests
|
|||||||
var encryptedContent = _encryption.Encrypt("Hello from history!");
|
var encryptedContent = _encryption.Encrypt("Hello from history!");
|
||||||
_chatService.HistoryToReturn =
|
_chatService.HistoryToReturn =
|
||||||
[
|
[
|
||||||
new(Guid.NewGuid(), encryptedContent, "bob", null, "general",
|
new(Guid.NewGuid(), encryptedContent, "bob", null, "general", DateTimeOffset.UtcNow)
|
||||||
MessageType.Text, null, null, DateTimeOffset.UtcNow)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
var lines = await RunAuthenticated(["JOIN #general"]);
|
var lines = await RunAuthenticated(["JOIN #general"]);
|
||||||
@@ -450,13 +493,27 @@ public class IrcCommandHandlerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Topic_SetAttempt_GetsPermissionDenied()
|
public async Task Topic_SetByNonCreator_GetsPermissionDenied()
|
||||||
{
|
{
|
||||||
|
_channelService.UpdateTopicResult = ChannelOperationResult.Fail(
|
||||||
|
ChannelError.Forbidden, "Only the channel creator can update the topic.");
|
||||||
|
|
||||||
var lines = await RunAuthenticated(["TOPIC #general :New topic"]);
|
var lines = await RunAuthenticated(["TOPIC #general :New topic"]);
|
||||||
|
|
||||||
Assert.Contains(lines, l => l.Contains("482") && l.Contains("channel creator"));
|
Assert.Contains(lines, l => l.Contains("482") && l.Contains("channel creator"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Topic_SetByCreator_UpdatesAndEchoesTopic()
|
||||||
|
{
|
||||||
|
_channelService.UpdateTopicResult = ChannelOperationResult.Success(
|
||||||
|
new ChannelDto(Guid.NewGuid(), "general", "New topic", true, 0, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
var lines = await RunAuthenticated(["TOPIC #general :New topic"]);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("TOPIC #general") && l.Contains("New topic"));
|
||||||
|
}
|
||||||
|
|
||||||
// ── WHO ──────────────────────────────────────────────────────────────
|
// ── WHO ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -564,9 +621,45 @@ public class IrcCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Mode_Channel_ReturnsChannelModes()
|
public async Task Mode_Channel_ReturnsChannelModes()
|
||||||
{
|
{
|
||||||
|
_channelService.ChannelByNameToReturn =
|
||||||
|
new ChannelDto(Guid.NewGuid(), "general", null, true, 0, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
var lines = await RunAuthenticated(["MODE #general"]);
|
var lines = await RunAuthenticated(["MODE #general"]);
|
||||||
|
|
||||||
Assert.Contains(lines, l => l.Contains("324") && l.Contains("#general"));
|
Assert.Contains(lines, l => l.Contains("324") && l.Contains("#general") && l.Contains("+"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Mode_ProtectedChannel_ReportsKeyMode()
|
||||||
|
{
|
||||||
|
_channelService.ChannelByNameToReturn =
|
||||||
|
new ChannelDto(Guid.NewGuid(), "secret", null, true, 0, DateTimeOffset.UtcNow, IsProtected: true);
|
||||||
|
|
||||||
|
var lines = await RunAuthenticated(["MODE #secret"]);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("324") && l.Contains("#secret") && l.Contains("+k"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Mode_SetKey_ByCreator_EchoesModeChange()
|
||||||
|
{
|
||||||
|
_channelService.SetPasswordResult = ChannelOperationResult.Success(
|
||||||
|
new ChannelDto(Guid.NewGuid(), "secret", null, true, 0, DateTimeOffset.UtcNow, IsProtected: true));
|
||||||
|
|
||||||
|
var lines = await RunAuthenticated(["MODE #secret +k hunter2"]);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("MODE #secret +k hunter2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Mode_SetKey_ByNonCreator_GetsPermissionDenied()
|
||||||
|
{
|
||||||
|
_channelService.SetPasswordResult = ChannelOperationResult.Fail(
|
||||||
|
ChannelError.Forbidden, "Only the channel creator or an admin can change the channel password.");
|
||||||
|
|
||||||
|
var lines = await RunAuthenticated(["MODE #secret +k hunter2"]);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("482"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -11,32 +11,31 @@ public class IrcMessageFormatterTests
|
|||||||
string channel = "general", List<EmbedDto>? embeds = null)
|
string channel = "general", List<EmbedDto>? embeds = null)
|
||||||
{
|
{
|
||||||
return new MessageDto(
|
return new MessageDto(
|
||||||
Guid.NewGuid(), content, sender, null, channel,
|
Guid.NewGuid(), content, sender, null, channel, DateTimeOffset.UtcNow, Embeds: embeds);
|
||||||
MessageType.Text, null, null, DateTimeOffset.UtcNow, Embeds: embeds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MessageDto CreateImageMessage(string asciiArt, string fileName = "image.png",
|
private static MessageDto CreateImageMessage(string asciiArt, string fileName = "image.png",
|
||||||
string url = "https://example.com/image.png", string sender = "alice", string channel = "general")
|
string url = "https://example.com/image.png", string sender = "alice", string channel = "general")
|
||||||
{
|
{
|
||||||
return new MessageDto(
|
return new MessageDto(
|
||||||
Guid.NewGuid(), asciiArt, sender, null, channel,
|
Guid.NewGuid(), "", sender, null, channel, DateTimeOffset.UtcNow,
|
||||||
MessageType.Image, url, fileName, DateTimeOffset.UtcNow);
|
[new AttachmentDto(AttachmentKind.Image, url, fileName, 0, asciiArt)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MessageDto CreateFileMessage(string fileName = "doc.pdf",
|
private static MessageDto CreateFileMessage(string fileName = "doc.pdf",
|
||||||
string url = "https://example.com/doc.pdf", string sender = "alice", string channel = "general")
|
string url = "https://example.com/doc.pdf", string sender = "alice", string channel = "general")
|
||||||
{
|
{
|
||||||
return new MessageDto(
|
return new MessageDto(
|
||||||
Guid.NewGuid(), "", sender, null, channel,
|
Guid.NewGuid(), "", sender, null, channel, DateTimeOffset.UtcNow,
|
||||||
MessageType.File, url, fileName, DateTimeOffset.UtcNow);
|
[new AttachmentDto(AttachmentKind.File, url, fileName, 0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MessageDto CreateAudioMessage(string fileName = "song.mp3",
|
private static MessageDto CreateAudioMessage(string fileName = "song.mp3",
|
||||||
string url = "https://example.com/song.mp3", string sender = "alice", string channel = "general")
|
string url = "https://example.com/song.mp3", string sender = "alice", string channel = "general")
|
||||||
{
|
{
|
||||||
return new MessageDto(
|
return new MessageDto(
|
||||||
Guid.NewGuid(), "", sender, null, channel,
|
Guid.NewGuid(), "", sender, null, channel, DateTimeOffset.UtcNow,
|
||||||
MessageType.Audio, url, fileName, DateTimeOffset.UtcNow);
|
[new AttachmentDto(AttachmentKind.Audio, url, fileName, 0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FormatMessage ────────────────────────────────────────────────────
|
// ── FormatMessage ────────────────────────────────────────────────────
|
||||||
@@ -115,8 +114,7 @@ public class IrcMessageFormatterTests
|
|||||||
var msg = CreateImageMessage("##\n##", "photo.jpg", "https://example.com/photo.jpg");
|
var msg = CreateImageMessage("##\n##", "photo.jpg", "https://example.com/photo.jpg");
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
Assert.Contains(lines, l => l.Contains("[Image: photo.jpg]"));
|
Assert.Contains(lines, l => l.Contains("[Image: photo.jpg]") && l.Contains("https://example.com/photo.jpg"));
|
||||||
Assert.Contains(lines, l => l.Contains("Download: https://example.com/photo.jpg"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ internal sealed class FakeChatService : IChatService
|
|||||||
// Configurable results
|
// Configurable results
|
||||||
public List<MessageDto> HistoryToReturn { get; set; } = [];
|
public List<MessageDto> HistoryToReturn { get; set; } = [];
|
||||||
public string? JoinError { get; set; }
|
public string? JoinError { get; set; }
|
||||||
|
public bool JoinPasswordRequired { get; set; }
|
||||||
public string? SendMessageError { get; set; }
|
public string? SendMessageError { get; set; }
|
||||||
public List<string> ChannelsForUserToReturn { get; set; } = [];
|
public List<string> ChannelsForUserToReturn { get; set; } = [];
|
||||||
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
|
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
|
||||||
@@ -171,11 +172,14 @@ internal sealed class FakeChatService : IChatService
|
|||||||
return Task.FromResult<string?>(null);
|
return Task.FromResult<string?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(
|
public List<string?> JoinKeys { get; } = [];
|
||||||
string connectionId, Guid userId, string username, string channelName)
|
|
||||||
|
public Task<(List<MessageDto> History, string? Error, bool PasswordRequired)> JoinChannelAsync(
|
||||||
|
string connectionId, Guid userId, string username, string channelName, string? password = null)
|
||||||
{
|
{
|
||||||
JoinedChannels.Add((channelName, username));
|
JoinedChannels.Add((channelName, username));
|
||||||
return Task.FromResult((HistoryToReturn, JoinError));
|
JoinKeys.Add(password);
|
||||||
|
return Task.FromResult((HistoryToReturn, JoinError, JoinPasswordRequired));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task LeaveChannelAsync(string connectionId, string username, string channelName)
|
public Task LeaveChannelAsync(string connectionId, string username, string channelName)
|
||||||
@@ -190,7 +194,7 @@ internal sealed class FakeChatService : IChatService
|
|||||||
return Task.FromResult(SendMessageError);
|
return Task.FromResult(SendMessageError);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count) =>
|
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0) =>
|
||||||
Task.FromResult(HistoryToReturn);
|
Task.FromResult(HistoryToReturn);
|
||||||
|
|
||||||
public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
|
public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
|
||||||
@@ -224,17 +228,36 @@ internal sealed class FakeChannelService : IChannelService
|
|||||||
public ChannelOperationResult? CreateResult { get; set; }
|
public ChannelOperationResult? CreateResult { get; set; }
|
||||||
public ChannelOperationResult? UpdateTopicResult { get; set; }
|
public ChannelOperationResult? UpdateTopicResult { get; set; }
|
||||||
public ChannelOperationResult? DeleteResult { get; set; }
|
public ChannelOperationResult? DeleteResult { get; set; }
|
||||||
public (bool Success, string? Error) MembershipResult { get; set; } = (true, null);
|
public ChannelOperationResult? SetPasswordResult { get; set; }
|
||||||
|
public (bool Success, string? Error, bool PasswordRequired) MembershipResult { get; set; } = (true, null, false);
|
||||||
|
|
||||||
public Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit) =>
|
public Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit) =>
|
||||||
Task.FromResult(new PaginatedResponse<ChannelDto>([], 0, offset, limit));
|
Task.FromResult(new PaginatedResponse<ChannelDto>([], 0, offset, limit));
|
||||||
|
|
||||||
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic) =>
|
public ChannelCryptoDto? CryptoToReturn { get; set; }
|
||||||
|
public ChannelOperationResult? RekeyResult { get; set; }
|
||||||
|
public (string? EncryptionSalt, string? WrappedRoomKey) KeyEnvelopeToReturn { get; set; }
|
||||||
|
|
||||||
|
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic,
|
||||||
|
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null) =>
|
||||||
Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
||||||
|
|
||||||
|
public Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName) =>
|
||||||
|
Task.FromResult(CryptoToReturn);
|
||||||
|
|
||||||
|
public Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName) =>
|
||||||
|
Task.FromResult(KeyEnvelopeToReturn);
|
||||||
|
|
||||||
|
public Task<ChannelOperationResult> RekeyChannelAsync(Guid callerUserId, string channelName,
|
||||||
|
string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey) =>
|
||||||
|
Task.FromResult(RekeyResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
||||||
|
|
||||||
public Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) =>
|
public Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) =>
|
||||||
Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
||||||
|
|
||||||
|
public Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password) =>
|
||||||
|
Task.FromResult(SetPasswordResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
||||||
|
|
||||||
public Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName) =>
|
public Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName) =>
|
||||||
Task.FromResult(DeleteResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
Task.FromResult(DeleteResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
||||||
|
|
||||||
@@ -247,7 +270,7 @@ internal sealed class FakeChannelService : IChannelService
|
|||||||
public Task<ChannelDto?> GetChannelByNameAsync(string channelName) =>
|
public Task<ChannelDto?> GetChannelByNameAsync(string channelName) =>
|
||||||
Task.FromResult(ChannelByNameToReturn);
|
Task.FromResult(ChannelByNameToReturn);
|
||||||
|
|
||||||
public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
|
public Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null) =>
|
||||||
Task.FromResult(MembershipResult);
|
Task.FromResult(MembershipResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,22 +8,18 @@ namespace EchoHub.Tests;
|
|||||||
public class IrcMessageFormatterTests
|
public class IrcMessageFormatterTests
|
||||||
{
|
{
|
||||||
private static MessageDto CreateMessage(
|
private static MessageDto CreateMessage(
|
||||||
MessageType type = MessageType.Text,
|
|
||||||
string content = "hello",
|
string content = "hello",
|
||||||
string sender = "alice",
|
string sender = "alice",
|
||||||
string channel = "general",
|
string channel = "general",
|
||||||
string? attachmentUrl = null,
|
List<AttachmentDto>? attachments = null,
|
||||||
string? attachmentFileName = null,
|
|
||||||
List<EmbedDto>? embeds = null) => new(
|
List<EmbedDto>? embeds = null) => new(
|
||||||
Id: Guid.NewGuid(),
|
Id: Guid.NewGuid(),
|
||||||
Content: content,
|
Content: content,
|
||||||
SenderUsername: sender,
|
SenderUsername: sender,
|
||||||
SenderNicknameColor: null,
|
SenderNicknameColor: null,
|
||||||
ChannelName: channel,
|
ChannelName: channel,
|
||||||
Type: type,
|
|
||||||
AttachmentUrl: attachmentUrl,
|
|
||||||
AttachmentFileName: attachmentFileName,
|
|
||||||
SentAt: DateTimeOffset.UtcNow,
|
SentAt: DateTimeOffset.UtcNow,
|
||||||
|
Attachments: attachments,
|
||||||
Embeds: embeds);
|
Embeds: embeds);
|
||||||
|
|
||||||
// ── FormatMessage ─────────────────────────────────────────────────
|
// ── FormatMessage ─────────────────────────────────────────────────
|
||||||
@@ -51,34 +47,29 @@ public class IrcMessageFormatterTests
|
|||||||
|
|
||||||
Assert.True(lines.Count >= 2);
|
Assert.True(lines.Count >= 2);
|
||||||
Assert.Contains("PRIVMSG #general :check this out", lines[0]);
|
Assert.Contains("PRIVMSG #general :check this out", lines[0]);
|
||||||
// Embed lines contain the Unicode pipe char and site/title
|
|
||||||
Assert.Contains("GitHub", lines[1]);
|
Assert.Contains("GitHub", lines[1]);
|
||||||
Assert.Contains("Repo Title", lines[1]);
|
Assert.Contains("Repo Title", lines[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_ImageMessage_IncludesImageTagAndDownloadUrl()
|
public void FormatMessage_ImageAttachment_IncludesImageTagAndDownloadUrl()
|
||||||
{
|
{
|
||||||
var msg = CreateMessage(
|
var msg = CreateMessage(
|
||||||
type: MessageType.Image,
|
content: "",
|
||||||
content: "{F:FF0000}\u2588{X}",
|
attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, "{F:FF0000}█{X}")]);
|
||||||
attachmentUrl: "/api/files/abc",
|
|
||||||
attachmentFileName: "photo.png");
|
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
Assert.True(lines.Count >= 2);
|
Assert.True(lines.Count >= 2);
|
||||||
Assert.Contains("[Image: photo.png]", lines[0]);
|
Assert.Contains("[Image: photo.png]", lines[0]);
|
||||||
Assert.Contains("Download: /api/files/abc", lines[1]);
|
Assert.Contains("/api/files/abc", lines[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_FileMessage_IncludesFileTag()
|
public void FormatMessage_FileAttachment_IncludesFileTag()
|
||||||
{
|
{
|
||||||
var msg = CreateMessage(
|
var msg = CreateMessage(
|
||||||
type: MessageType.File,
|
content: "",
|
||||||
content: "report.pdf",
|
attachments: [new AttachmentDto(AttachmentKind.File, "/api/files/xyz", "report.pdf", 0)]);
|
||||||
attachmentUrl: "/api/files/xyz",
|
|
||||||
attachmentFileName: "report.pdf");
|
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
Assert.Single(lines);
|
Assert.Single(lines);
|
||||||
@@ -87,21 +78,49 @@ public class IrcMessageFormatterTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_AudioMessage_IncludesMusicNoteAndAudioTag()
|
public void FormatMessage_AudioAttachment_IncludesMusicNoteAndAudioTag()
|
||||||
{
|
{
|
||||||
var msg = CreateMessage(
|
var msg = CreateMessage(
|
||||||
type: MessageType.Audio,
|
content: "",
|
||||||
content: "song.mp3",
|
attachments: [new AttachmentDto(AttachmentKind.Audio, "/api/files/def", "song.mp3", 0)]);
|
||||||
attachmentUrl: "/api/files/def",
|
|
||||||
attachmentFileName: "song.mp3");
|
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
Assert.Single(lines);
|
Assert.Single(lines);
|
||||||
Assert.Contains("\u266a", lines[0]); // ♪
|
Assert.Contains("♪", lines[0]);
|
||||||
Assert.Contains("[Audio: song.mp3]", lines[0]);
|
Assert.Contains("[Audio: song.mp3]", lines[0]);
|
||||||
Assert.Contains("/api/files/def", lines[0]);
|
Assert.Contains("/api/files/def", lines[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatMessage_CaptionWithAttachment_RendersBoth()
|
||||||
|
{
|
||||||
|
var msg = CreateMessage(
|
||||||
|
content: "check this photo",
|
||||||
|
attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/p", "pic.png", 0, null)]);
|
||||||
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("check this photo"));
|
||||||
|
Assert.Contains(lines, l => l.Contains("[Image: pic.png]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatMessage_MultipleAttachments_RendersEach()
|
||||||
|
{
|
||||||
|
var msg = CreateMessage(
|
||||||
|
content: "",
|
||||||
|
attachments:
|
||||||
|
[
|
||||||
|
new AttachmentDto(AttachmentKind.Image, "/api/files/1", "a.png", 0, null),
|
||||||
|
new AttachmentDto(AttachmentKind.Audio, "/api/files/2", "b.mp3", 0),
|
||||||
|
new AttachmentDto(AttachmentKind.File, "/api/files/3", "c.pdf", 0),
|
||||||
|
]);
|
||||||
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("[Image: a.png]"));
|
||||||
|
Assert.Contains(lines, l => l.Contains("[Audio: b.mp3]"));
|
||||||
|
Assert.Contains(lines, l => l.Contains("[File: c.pdf]"));
|
||||||
|
}
|
||||||
|
|
||||||
// ── ColorTagsToAnsi ───────────────────────────────────────────────
|
// ── ColorTagsToAnsi ───────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -179,7 +198,6 @@ public class IrcMessageFormatterTests
|
|||||||
var longWord = new string('a', 500);
|
var longWord = new string('a', 500);
|
||||||
var result = IrcMessageFormatter.SplitMessage(longWord, 400);
|
var result = IrcMessageFormatter.SplitMessage(longWord, 400);
|
||||||
|
|
||||||
// Single word can't be split at word boundary, so it stays as one chunk
|
|
||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
Assert.Equal(longWord, result[0]);
|
Assert.Equal(longWord, result[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using EchoHub.Core.Security;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class RoomCryptoTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EncryptText_RoundTrips()
|
||||||
|
{
|
||||||
|
var key = RoomCrypto.GenerateRoomKey();
|
||||||
|
var ciphertext = RoomCrypto.EncryptText("hello secret room", key);
|
||||||
|
|
||||||
|
Assert.StartsWith("$RC1$", ciphertext);
|
||||||
|
Assert.True(RoomCrypto.TryDecryptText(ciphertext, key, out var plaintext));
|
||||||
|
Assert.Equal("hello secret room", plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryDecryptText_WrongKey_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var ciphertext = RoomCrypto.EncryptText("hello", RoomCrypto.GenerateRoomKey());
|
||||||
|
|
||||||
|
Assert.False(RoomCrypto.TryDecryptText(ciphertext, RoomCrypto.GenerateRoomKey(), out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryDecryptText_PlainText_ReturnsFalse()
|
||||||
|
{
|
||||||
|
Assert.False(RoomCrypto.TryDecryptText("just a normal message", RoomCrypto.GenerateRoomKey(), out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncryptBytes_RoundTrips()
|
||||||
|
{
|
||||||
|
var key = RoomCrypto.GenerateRoomKey();
|
||||||
|
var payload = new byte[4096];
|
||||||
|
Random.Shared.NextBytes(payload);
|
||||||
|
|
||||||
|
var blob = RoomCrypto.EncryptBytes(payload, key);
|
||||||
|
var decrypted = RoomCrypto.DecryptBytes(blob, key);
|
||||||
|
|
||||||
|
Assert.Equal(payload, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeriveKeys_IsDeterministic_AndSaltSensitive()
|
||||||
|
{
|
||||||
|
var salt = RoomCrypto.GenerateSalt();
|
||||||
|
var a = RoomCrypto.DeriveKeys("correct horse battery staple", salt);
|
||||||
|
var b = RoomCrypto.DeriveKeys("correct horse battery staple", salt);
|
||||||
|
var other = RoomCrypto.DeriveKeys("correct horse battery staple", RoomCrypto.GenerateSalt());
|
||||||
|
|
||||||
|
Assert.Equal(a.AuthKeyHex, b.AuthKeyHex);
|
||||||
|
Assert.Equal(a.KeyEncryptionKey, b.KeyEncryptionKey);
|
||||||
|
Assert.NotEqual(a.AuthKeyHex, other.AuthKeyHex);
|
||||||
|
Assert.NotEqual(a.AuthKeyHex, Convert.ToHexString(a.KeyEncryptionKey).ToLowerInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WrapRoomKey_UnwrapsWithSameKek_FailsWithWrongKek()
|
||||||
|
{
|
||||||
|
var salt = RoomCrypto.GenerateSalt();
|
||||||
|
var keys = RoomCrypto.DeriveKeys("passphrase-1", salt);
|
||||||
|
var wrongKeys = RoomCrypto.DeriveKeys("passphrase-2", salt);
|
||||||
|
var roomKey = RoomCrypto.GenerateRoomKey();
|
||||||
|
|
||||||
|
var wrapped = RoomCrypto.WrapRoomKey(roomKey, keys.KeyEncryptionKey);
|
||||||
|
|
||||||
|
Assert.True(RoomCrypto.TryUnwrapRoomKey(wrapped, keys.KeyEncryptionKey, out var unwrapped));
|
||||||
|
Assert.Equal(roomKey, unwrapped);
|
||||||
|
Assert.False(RoomCrypto.TryUnwrapRoomKey(wrapped, wrongKeys.KeyEncryptionKey, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rewrap_PreservesRoomKey_AcrossPassphraseChange()
|
||||||
|
{
|
||||||
|
// Simulates a passphrase change: unwrap with old KEK, wrap with new KEK.
|
||||||
|
var roomKey = RoomCrypto.GenerateRoomKey();
|
||||||
|
|
||||||
|
var oldSalt = RoomCrypto.GenerateSalt();
|
||||||
|
var oldKeys = RoomCrypto.DeriveKeys("old-passphrase", oldSalt);
|
||||||
|
var wrappedOld = RoomCrypto.WrapRoomKey(roomKey, oldKeys.KeyEncryptionKey);
|
||||||
|
|
||||||
|
Assert.True(RoomCrypto.TryUnwrapRoomKey(wrappedOld, oldKeys.KeyEncryptionKey, out var recovered));
|
||||||
|
|
||||||
|
var newSalt = RoomCrypto.GenerateSalt();
|
||||||
|
var newKeys = RoomCrypto.DeriveKeys("new-passphrase", newSalt);
|
||||||
|
var wrappedNew = RoomCrypto.WrapRoomKey(recovered, newKeys.KeyEncryptionKey);
|
||||||
|
|
||||||
|
Assert.True(RoomCrypto.TryUnwrapRoomKey(wrappedNew, newKeys.KeyEncryptionKey, out var final));
|
||||||
|
Assert.Equal(roomKey, final);
|
||||||
|
|
||||||
|
// Old messages encrypted before the change still decrypt with the unwrapped key
|
||||||
|
var oldMessage = RoomCrypto.EncryptText("written before rekey", roomKey);
|
||||||
|
Assert.True(RoomCrypto.TryDecryptText(oldMessage, final, out var plaintext));
|
||||||
|
Assert.Equal("written before rekey", plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user