mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 23:34:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f4af9a543 | ||
|
|
b62729dc95 | ||
|
|
aae788028e | ||
|
|
c048b39e42 | ||
|
|
3886e7148c | ||
|
|
b9a80806e1 | ||
|
|
e797ec2542 | ||
|
|
dbf6565d18 | ||
|
|
494dcb46cf | ||
|
|
ce41ef9c0a | ||
|
|
9aea6ecfc3 | ||
|
|
5040c5c201 | ||
|
|
a3a413f0b1 | ||
|
|
6292e82cec | ||
|
|
2d33773c24 | ||
|
|
e05b420ce9 | ||
|
|
ea8e583ee5 | ||
|
|
3ca9dbfd91 | ||
|
|
15187c4665 | ||
|
|
64bca51619 | ||
|
|
3538ec8005 | ||
|
|
1b234e39e1 | ||
|
|
19bf123c8c | ||
|
|
6cf284a475 |
@@ -33,6 +33,8 @@ jobs:
|
|||||||
|
|
||||||
build-and-test:
|
build-and-test:
|
||||||
name: Build & Test
|
name: Build & Test
|
||||||
|
# Run the cheap, fast checks first; only spend build/test compute if they pass.
|
||||||
|
needs: [format-check, lint-markdown]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
name: Docker
|
name: Docker
|
||||||
|
|
||||||
|
# Runs only after the CI workflow completes, so an image is never pushed for a commit
|
||||||
|
# whose formatting, lint, build, or tests failed.
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: ["CI"]
|
||||||
|
types: [completed]
|
||||||
branches: [master]
|
branches: [master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -15,23 +19,29 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
docker:
|
docker:
|
||||||
name: Build & Push Docker Image
|
name: Build & Push Docker Image
|
||||||
|
# Proceed only for a successful CI run on a master push, or a manual dispatch.
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event == 'push' &&
|
||||||
|
github.event.workflow_run.head_branch == 'master')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
# The exact commit CI tested (workflow_run), or the current tip (manual dispatch).
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
|
|
||||||
- name: Check for src/ changes
|
- name: Check for src/ changes
|
||||||
id: changes
|
id: changes
|
||||||
env:
|
|
||||||
BEFORE: ${{ github.event.before }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
||||||
echo "src_changed=true" >> "$GITHUB_OUTPUT"
|
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'src/' | wc -l)
|
||||||
else
|
else
|
||||||
CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
|
CHANGED=1
|
||||||
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
fi
|
||||||
|
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Read version
|
- name: Read version
|
||||||
if: steps.changes.outputs.src_changed == 'true'
|
if: steps.changes.outputs.src_changed == 'true'
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
|
# Runs only after the CI workflow completes, so a release is never published on a commit
|
||||||
|
# whose formatting, lint, build, or tests failed.
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: ["CI"]
|
||||||
|
types: [completed]
|
||||||
branches: [master]
|
branches: [master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -11,6 +15,12 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
|
# Proceed only for a successful CI run on a master push, or a manual dispatch.
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event == 'push' &&
|
||||||
|
github.event.workflow_run.head_branch == 'master')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
should_release: ${{ steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' }}
|
should_release: ${{ steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' }}
|
||||||
@@ -21,18 +31,18 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
# The exact commit CI tested (workflow_run), or the current tip (manual dispatch).
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
|
|
||||||
- name: Check for src/ changes
|
- name: Check for src/ changes
|
||||||
id: changes
|
id: changes
|
||||||
env:
|
|
||||||
BEFORE: ${{ github.event.before }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
||||||
echo "src_changed=true" >> "$GITHUB_OUTPUT"
|
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'src/' | wc -l)
|
||||||
else
|
else
|
||||||
CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
|
CHANGED=1
|
||||||
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
fi
|
||||||
|
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Read version
|
- name: Read version
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Encrypted Rooms (Password-Protected Channels)
|
||||||
|
|
||||||
|
An **encrypted room** is a channel whose entire content — every message and every file — is
|
||||||
|
end-to-end encrypted with a key derived from a shared passphrase. Only people who know the
|
||||||
|
passphrase can read the room. **Not even the server owner can read the content**, yet the server
|
||||||
|
can still gate who joins, and it can count and measure what's stored (message count, file sizes,
|
||||||
|
timestamps) without ever seeing the plaintext.
|
||||||
|
|
||||||
|
This is a stronger guarantee than the [transport and at-rest encryption](encryption.md) described
|
||||||
|
elsewhere, where the server decrypts every message to process it. Here the server is treated as
|
||||||
|
*untrusted* for content: it holds only ciphertext and wrapped keys.
|
||||||
|
|
||||||
|
> **The passphrase is the only key.** There is no recovery. If everyone who knows a room's
|
||||||
|
> passphrase forgets it, that room's history is permanently unreadable — by design.
|
||||||
|
|
||||||
|
## What the server can and cannot see
|
||||||
|
|
||||||
|
| The server **can** see | The server **cannot** see |
|
||||||
|
| --- | --- |
|
||||||
|
| That the channel is encrypted | Message text |
|
||||||
|
| Message count and timestamps | File contents |
|
||||||
|
| Who sent each message (sender identity) | Image previews (ASCII art) |
|
||||||
|
| Each attachment's **file name** and byte size | The passphrase, the room key, or the key-encryption key |
|
||||||
|
| The estimated total size (via `/meta`) | Anything that would let it decrypt the above |
|
||||||
|
|
||||||
|
File **names are stored in plaintext** so the file list stays usable — treat a file name itself as
|
||||||
|
non-secret. Everything *inside* the file is encrypted.
|
||||||
|
|
||||||
|
## Key hierarchy
|
||||||
|
|
||||||
|
Three keys are derived from one passphrase. The passphrase, the key-encryption key, and the room
|
||||||
|
content key **never leave the client**.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
P[Passphrase] -->|PBKDF2-SHA256, 210k iterations, per-room salt| OKM[64-byte output]
|
||||||
|
OKM --> AK["Auth key (first 32 bytes)"]
|
||||||
|
OKM --> KEK["Key-encryption key / KEK (last 32 bytes)"]
|
||||||
|
AK -->|sent as lowercase hex| SRV1["Server: BCrypt-hash as the join gate"]
|
||||||
|
RCK["Room Content Key (random 256-bit)"] -->|encrypts all content| CONTENT[Messages + files + previews]
|
||||||
|
RCK -->|AES-256-GCM wrap under KEK| WRAP["Wrapped room key"]
|
||||||
|
WRAP -->|stored| SRV2["Server: stores wrapped key + salt only"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Auth key** — the join credential. Derived from the passphrase, sent to the server as hex, and
|
||||||
|
stored only as a **BCrypt hash**. Proving knowledge of it is what lets you join; it reveals
|
||||||
|
nothing about the content key.
|
||||||
|
- **Key-encryption key (KEK)** — never sent. Used locally to *wrap* (encrypt) and *unwrap* the room
|
||||||
|
content key.
|
||||||
|
- **Room Content Key (RCK)** — a random 256-bit key generated once, at room creation. It encrypts
|
||||||
|
every message and file. The server stores it only in wrapped form, so it can hand the wrapped key
|
||||||
|
to a joiner but can never unwrap it itself.
|
||||||
|
|
||||||
|
All content encryption is **AES-256-GCM** with a random 12-byte nonce and a 16-byte authentication
|
||||||
|
tag per item, so identical inputs never produce identical ciphertext, and any tampering is detected.
|
||||||
|
|
||||||
|
Room-encrypted text carries a self-describing prefix so clients and the server can tell it apart
|
||||||
|
from plaintext:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$RC1$base64(nonce || tag || ciphertext)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a room
|
||||||
|
|
||||||
|
The client does all the cryptography locally, then hands the server only what it needs to gate joins
|
||||||
|
and store (but not read) the content.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant Server
|
||||||
|
|
||||||
|
Client->>Client: generate salt + random Room Content Key (RCK)
|
||||||
|
Client->>Client: DeriveKeys(passphrase, salt) → auth key + KEK
|
||||||
|
Client->>Client: wrap RCK under KEK
|
||||||
|
Client->>Server: create channel { authKey(hex), salt, wrappedRoomKey }
|
||||||
|
Server->>Server: BCrypt-hash auth key, store salt + wrapped key
|
||||||
|
Note over Server: Server never receives passphrase, KEK, or RCK
|
||||||
|
```
|
||||||
|
|
||||||
|
## Joining a room
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant Server
|
||||||
|
|
||||||
|
Client->>Server: GET /crypto → { isEncrypted, salt }
|
||||||
|
Client->>Client: DeriveKeys(passphrase, salt) → auth key + KEK
|
||||||
|
Client->>Server: join { authKey(hex) }
|
||||||
|
Server->>Server: BCrypt-verify against stored hash
|
||||||
|
alt correct passphrase
|
||||||
|
Server->>Client: history (ciphertext) + wrapped room key
|
||||||
|
Client->>Client: unwrap RCK with KEK, then decrypt everything locally
|
||||||
|
else wrong passphrase
|
||||||
|
Server->>Client: rejected (join gate fails)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
A wrong passphrase fails the BCrypt gate, so the server never even hands out the wrapped key. Even if
|
||||||
|
it did, an attacker without the KEK cannot unwrap it.
|
||||||
|
|
||||||
|
## What gets encrypted
|
||||||
|
|
||||||
|
When you send a message or attach files to an encrypted room, the client encrypts each part with the
|
||||||
|
room content key **before** uploading:
|
||||||
|
|
||||||
|
- **Message text** → `$RC1$…` ciphertext.
|
||||||
|
- **Files** (any kind) → the whole blob is AES-256-GCM encrypted client-side; the server stores an
|
||||||
|
opaque ciphertext blob.
|
||||||
|
- **Image ASCII previews** → rendered on the client, then room-encrypted. The server never sees the
|
||||||
|
rendered art.
|
||||||
|
|
||||||
|
The server records each attachment's **kind**, **file name**, and **byte size** (of the ciphertext
|
||||||
|
blob) as metadata, and broadcasts the ciphertext to other members, who decrypt locally.
|
||||||
|
|
||||||
|
## Changing the passphrase
|
||||||
|
|
||||||
|
`/passwd <old> <new>` rotates the passphrase. Because only the *wrapping* of the room content key
|
||||||
|
changes — not the RCK itself — **all existing history stays readable**:
|
||||||
|
|
||||||
|
1. The client proves knowledge of the old passphrase (old auth key).
|
||||||
|
2. It unwraps the RCK with the old KEK, then re-wraps it under the new KEK (new salt).
|
||||||
|
3. It uploads the new auth key + salt + re-wrapped key. The content is never re-encrypted.
|
||||||
|
|
||||||
|
## Inspecting a room
|
||||||
|
|
||||||
|
Use `/meta` in any channel to see what the server knows about it, including encrypted rooms:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Room info for #private-room:
|
||||||
|
Room ID 3f2a…-…-…
|
||||||
|
Created 7/16/2026 2:31 PM
|
||||||
|
Messages 128
|
||||||
|
Unique users 4
|
||||||
|
Est. size 42.5 MB
|
||||||
|
Protection end-to-end encrypted
|
||||||
|
```
|
||||||
|
|
||||||
|
`Est. size` is the sum of stored attachment blob sizes plus message text length — an estimate of the
|
||||||
|
room's footprint, computed entirely from metadata the server holds without reading any content.
|
||||||
|
|
||||||
|
## Limitations & security notes
|
||||||
|
|
||||||
|
- **No recovery.** A lost passphrase means unrecoverable history. Keep it safe; there is no reset.
|
||||||
|
- **File names are plaintext.** They stay readable so the file list works — don't put secrets in a
|
||||||
|
file name.
|
||||||
|
- **IRC is disabled for encrypted rooms.** The IRC gateway forwards plaintext and cannot participate
|
||||||
|
in the room's key scheme, so encrypted channels are not bridged to IRC.
|
||||||
|
- **Metadata is visible.** Message counts, timestamps, sender identities, file names, and sizes are
|
||||||
|
intentionally readable so the server can moderate at the metadata level and report `/meta`.
|
||||||
|
- **Endpoint trust.** End-to-end encryption protects content from the server and the network, not
|
||||||
|
from a compromised client device that already holds the passphrase.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Message Encryption](encryption.md) — transport (`$ENC$v1$`) and optional at-rest database
|
||||||
|
encryption, where the server *does* decrypt content for processing. Encrypted rooms are a separate,
|
||||||
|
stronger layer that sits on top.
|
||||||
@@ -118,7 +118,7 @@ If you need to recover old encrypted messages, restore the original key from a b
|
|||||||
### Limitations
|
### Limitations
|
||||||
|
|
||||||
- **TLS-inspecting proxies** — if a corporate proxy terminates TLS with a trusted root CA, it can intercept the key exchange (`GET /api/server/encryption-key`) and read all traffic. A future upgrade to ECDH key exchange would address this.
|
- **TLS-inspecting proxies** — if a corporate proxy terminates TLS with a trusted root CA, it can intercept the key exchange (`GET /api/server/encryption-key`) and read all traffic. A future upgrade to ECDH key exchange would address this.
|
||||||
- **Server has full access** — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server.
|
- **Server has full access** — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server. For true end-to-end encryption where the server cannot read content, use [encrypted rooms](encrypted-rooms.md).
|
||||||
- **IRC clients receive plaintext** — IRC is an open protocol and third-party clients cannot participate in the encryption scheme.
|
- **IRC clients receive plaintext** — IRC is an open protocol and third-party clients cannot participate in the encryption scheme.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
href: configuration.md
|
href: configuration.md
|
||||||
- name: Encryption
|
- name: Encryption
|
||||||
href: encryption.md
|
href: encryption.md
|
||||||
|
- name: Encrypted Rooms
|
||||||
|
href: encrypted-rooms.md
|
||||||
- name: Notification Sounds
|
- name: Notification Sounds
|
||||||
href: notification-sounds.md
|
href: notification-sounds.md
|
||||||
- name: Flows
|
- name: Flows
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions
|
||||||
|
- [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.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.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.9](v0.2.9.md) - Install Script & Chocolatey Fixes
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.13
|
||||||
|
href: v0.2.13.md
|
||||||
|
- name: v0.2.12
|
||||||
|
href: v0.2.12.md
|
||||||
- name: v0.2.11
|
- name: v0.2.11
|
||||||
href: v0.2.11.md
|
href: v0.2.11.md
|
||||||
- name: v0.2.10
|
- name: v0.2.10
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 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`)
|
||||||
|
- **`/meta` command** — shows a summary of the current room: room id, created date, message count, unique participant count, estimated storage size, and protection level (open / password-protected / end-to-end encrypted). Works for encrypted rooms too, since these are all metadata the server tracks without reading content. (`/info` is an alias.)
|
||||||
|
- **Configurable upload limits** — a new `Uploads` section in the server settings sets the maximum size per file, image, audio clip, and avatar, plus the maximum attachments per message. Absent values fall back to the previous built-in limits, so existing servers are unaffected until they opt in.
|
||||||
|
- **Updater progress bar** — the self-updater now draws a real progress bar on the console (`Downloading [████████░░] 62%`) for each step, replacing the plain status lines, so a large download shows visible progress.
|
||||||
|
- Timestamps in messages are culture-aware and now **always include the date**: today's messages show a compact date + short time, and older messages show the general short date/time.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- Fixed new messages showing no date — today's messages rendered time-only, and the "is it today?" check compared the server's UTC date against the local date, so the classification could also be wrong near midnight. Timestamps are now converted to local time first and always include the date.
|
||||||
|
- Fixed the self-updater hanging at "extracting" — the update ran while the Terminal.Gui main loop still owned the console, so the old and new processes deadlocked over it. The update now runs after the TUI shuts down, on a clean console
|
||||||
|
- 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)
|
||||||
|
- New endpoint `GET /api/channels/{channel}/meta` returns a `ChannelMetaDto` (room id, name, topic, encrypted/protected flags, message count, unique user count, estimated size, created date) backing the `/meta` command
|
||||||
|
- New server `Uploads` configuration section (`MaxFileSizeMB`, `MaxImageSizeMB`, `MaxAudioSizeMB`, `MaxAvatarSizeMB`, `MaxAttachmentsPerMessage`); the message-upload endpoint's request-body ceiling is now derived from these values at runtime rather than from compile-time constants
|
||||||
|
- 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
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# v0.2.13
|
||||||
|
|
||||||
|
A visual overhaul of the chat client — WeeChat-style column layout, per-user nick colors, date rules, unread markers, an activity status segment, and an ASCII welcome banner — plus reliable cross-channel notifications: the client now joins all your channels at connect so unread counts and @mentions light up everywhere, and read positions persist across restarts so activity that happened while you were offline still shows. Modern polish, old IRC soul.
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- **Aligned message layout (WeeChat-style)** — every message renders as `HH:mm nickname │ text`: a dim timestamp, the nick right-aligned in a fixed 12-column gutter, and a dim `│` rail separating names from content. Word-wrapped continuation lines, multi-line messages, attachments, ASCII-art image previews, and link embeds all align under the text column and extend the rail, so conversations read as a clean two-column grid. Over-long nicks are truncated with `…` in the gutter only — profiles, mentions, and the users panel always use the full name. System and status lines (`joined`, `is now Away`, …) show `--` in the nick column, replacing the old `**` prefix.
|
||||||
|
- **Deterministic nickname colors** — users who haven't picked a `/color` now get an automatic, stable color derived from their nick (12-entry palette chosen to stay readable on dark and light backgrounds). The same user is the same color in message headers and the online-users panel, on every client, every session. An explicitly chosen nickname color still takes priority.
|
||||||
|
- **Date separator rules** — a full-width `── Wed, Jul 16 2026 ─────` rule is inserted whenever the calendar day changes (live, in loaded history, and when scrolling back through older messages). Timestamps are now a compact `HH:mm` — the date lives in the rules instead of being repeated on every line.
|
||||||
|
- **"New messages" marker** — the first message that arrives in a channel you're not viewing gets an orange `── new messages ──` rule above it (irssi-style). Switch to the channel and the marker shows exactly where you left off; it survives the history reload on channel switch and is consumed when you move away again.
|
||||||
|
- **Status bar activity segment** — channels with unread messages appear in the status bar as `│ Act: #dev,#random` (up to 4, `+n` overflow). Channels where you were @mentioned show in orange, others in cyan.
|
||||||
|
- **Connecting spinner** — transitional connection states (Connecting, Reconnecting, Authenticating…) now animate a braille spinner in the status bar instead of sitting on static text.
|
||||||
|
- **Mention-aware channel list** — a channel where you were @mentioned turns orange in the channel list (name and unread badge), escalating above the cyan plain-unread highlight. The highlight clears when you view the channel.
|
||||||
|
- **Welcome banner** — with no channel selected (fresh start, or after disconnecting) the chat pane shows a gold-gradient ASCII "ECHOHUB" logo with the version and key hints, instead of an empty box. Narrow panes get a compact variant.
|
||||||
|
- **Rounded frame borders** — the channels, chat, input, and users panels draw with rounded corners.
|
||||||
|
- **Auto-join all channels at connect** — the client now joins every channel it lists for you (public channels and prior memberships) as part of connecting, so message events flow for all of them: unread badges, @mention highlights, and the status-bar activity segment work without having to open each channel first. Protected channels you've never entered are skipped silently (joining them stays a prompted, manual action), and channels you `/leave` are remembered and excluded until you join them again.
|
||||||
|
- **Read positions survive restarts** — the client persists the last message you read per channel (locally, per server). On the next connect it compares that against fetched history, so messages that arrived while you were offline still produce unread counts, mention highlights, and a correctly placed "new messages" marker.
|
||||||
|
- **Theme-tinted frame borders** — themes can now color the window frame borders independently of text via a new optional `border` section (hex values supported). The Transparent and TransparentLight themes use it to draw dim gray borders instead of stark white ones, for a subtler, glassier look. Custom theme JSON files without a `border` section keep their base colors.
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- The @mention highlight no longer drops off the second and later lines of a long mention message — wrapped continuation lines now inherit the highlight.
|
||||||
|
- The unread-position marker is re-anchored after a channel switch reloads history, instead of being wiped by the reload before you could see it.
|
||||||
|
- Cached room keys for end-to-end encrypted channels no longer disappear from the config on reconnect. Saving the server entry after a successful connect replaced it wholesale, wiping the cached keys (and forcing a passphrase re-entry on the next launch); the entry is now updated in place.
|
||||||
|
- A failed channel rejoin after a reconnect (e.g. a channel deleted while you were away) no longer aborts rejoining the remaining channels.
|
||||||
|
- Client config file access is now serialized across threads (token refresh, room-key cache, and read-position checkpoints all write it), preventing rare config corruption.
|
||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
**/obj/
|
||||||
|
**/bin/
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.11</Version>
|
<Version>0.2.13</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;
|
||||||
@@ -31,12 +33,19 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
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 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();
|
||||||
|
|
||||||
public MainWindow MainWindow => _mainWindow;
|
public MainWindow MainWindow => _mainWindow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set when the user confirms an update. The host must run this after the Terminal.Gui main
|
||||||
|
/// loop exits (console restored), so the updater's in-place restart doesn't fight the TUI.
|
||||||
|
/// </summary>
|
||||||
|
public Func<Task>? PendingUpdate => _updateService.PendingUpdate;
|
||||||
|
|
||||||
public AppOrchestrator(IApplication app, ClientConfig config)
|
public AppOrchestrator(IApplication app, ClientConfig config)
|
||||||
{
|
{
|
||||||
_app = app;
|
_app = app;
|
||||||
@@ -58,6 +67,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
// Quit while still connected — capture read positions before tearing down
|
||||||
|
PersistLastReads();
|
||||||
_conn.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
_conn.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
_updateService.Dispose();
|
_updateService.Dispose();
|
||||||
}
|
}
|
||||||
@@ -88,6 +99,8 @@ 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;
|
||||||
@@ -109,9 +122,14 @@ 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;
|
||||||
|
_commandHandler.OnRoomInfo += HandleCmdMeta;
|
||||||
_commandHandler.OnKickUser += HandleCmdKickUser;
|
_commandHandler.OnKickUser += HandleCmdKickUser;
|
||||||
_commandHandler.OnBanUser += HandleCmdBanUser;
|
_commandHandler.OnBanUser += HandleCmdBanUser;
|
||||||
_commandHandler.OnUnbanUser += HandleCmdUnbanUser;
|
_commandHandler.OnUnbanUser += HandleCmdUnbanUser;
|
||||||
@@ -158,32 +176,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)
|
||||||
@@ -216,13 +377,19 @@ 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
|
||||||
|
|
||||||
|
// A deliberate join cancels any earlier /leave exclusion
|
||||||
|
UpdateServerConfig(server =>
|
||||||
|
server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
_mainWindow.EnsureChannelInList(channelName);
|
_mainWindow.EnsureChannelInList(channelName);
|
||||||
@@ -237,6 +404,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;
|
||||||
@@ -253,6 +559,14 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _conn.LeaveChannelAsync(channel);
|
await _conn.LeaveChannelAsync(channel);
|
||||||
|
|
||||||
|
// Remember the leave so the connect-time auto-join doesn't pull us back in
|
||||||
|
UpdateServerConfig(server =>
|
||||||
|
{
|
||||||
|
if (!server.LeftChannels.Contains(channel, StringComparer.OrdinalIgnoreCase))
|
||||||
|
server.LeftChannels.Add(channel);
|
||||||
|
});
|
||||||
|
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"You left #{channel}"));
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"You left #{channel}"));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -312,6 +626,46 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandleCmdMeta()
|
||||||
|
{
|
||||||
|
if (!_conn.IsConnected || _conn.Api is null) return;
|
||||||
|
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = await _conn.Api.GetChannelMetaAsync(channel);
|
||||||
|
if (meta is null)
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"Channel #{channel} not found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var size = meta.EstimatedSizeBytes <= 0 ? "0 B" : ChatMessageManager.FormatFileSize(meta.EstimatedSizeBytes);
|
||||||
|
var protection = meta.IsEncrypted ? "end-to-end encrypted"
|
||||||
|
: meta.IsProtected ? "password-protected"
|
||||||
|
: "open";
|
||||||
|
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_messageManager.AddSystemMessage(channel, $"Room info for #{meta.Name}:");
|
||||||
|
if (!string.IsNullOrWhiteSpace(meta.Topic))
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Topic {meta.Topic}");
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Room ID {meta.Id}");
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Created {meta.CreatedAt.ToLocalTime():g}");
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Messages {meta.MessageCount}");
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Unique users {meta.UniqueUserCount}");
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Est. size {size}");
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Protection {protection}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"Failed to fetch room info: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleCmdKickUser(string username, string? reason)
|
private async Task HandleCmdKickUser(string username, string? reason)
|
||||||
{
|
{
|
||||||
if (!_conn.IsAuthenticated) return;
|
if (!_conn.IsAuthenticated) return;
|
||||||
@@ -554,7 +908,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);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -617,13 +971,28 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
_session.Username = result.Login.Username;
|
_session.Username = result.Login.Username;
|
||||||
|
|
||||||
|
// Persisted last-read markers for this server — used to seed unread counts,
|
||||||
|
// mention highlights, and "new messages" markers from the fetched histories.
|
||||||
|
var lastReads = ConfigManager.Load().SavedServers
|
||||||
|
.FirstOrDefault(s => string.Equals(s.Url, dialogResult.ServerUrl, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?.LastReadMessages ?? [];
|
||||||
|
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
_mainWindow.SetCurrentUser(result.Login.DisplayName ?? result.Login.Username);
|
_mainWindow.SetCurrentUser(result.Login.DisplayName ?? result.Login.Username);
|
||||||
_mainWindow.SetChannels(result.Channels);
|
_mainWindow.SetChannels(result.Channels);
|
||||||
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
|
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
|
||||||
if (result.DefaultHistory.Count > 0)
|
|
||||||
_messageManager.LoadHistory(HubConstants.DefaultChannel, result.DefaultHistory);
|
foreach (var (channel, history) in result.Histories)
|
||||||
|
{
|
||||||
|
if (history.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Guid? lastRead = lastReads.TryGetValue(channel, out var idText)
|
||||||
|
&& Guid.TryParse(idText, out var id) ? id : null;
|
||||||
|
_messageManager.LoadHistory(channel, history, lastRead);
|
||||||
|
}
|
||||||
|
|
||||||
_mainWindow.FocusInput();
|
_mainWindow.FocusInput();
|
||||||
FetchAndUpdateOnlineUsers();
|
FetchAndUpdateOnlineUsers();
|
||||||
});
|
});
|
||||||
@@ -635,6 +1004,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
Log.Information("Disconnecting from server");
|
Log.Information("Disconnecting from server");
|
||||||
lock (_channelUsersLock) _channelUsers.Clear();
|
lock (_channelUsersLock) _channelUsers.Clear();
|
||||||
|
PersistLastReads();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -650,6 +1020,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private void HandleLogout()
|
private void HandleLogout()
|
||||||
{
|
{
|
||||||
Log.Information("Logging out from server");
|
Log.Information("Logging out from server");
|
||||||
|
PersistLastReads();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -695,19 +1066,52 @@ 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;
|
||||||
|
|
||||||
|
// Checkpoint read positions — the previous channel was just marked read
|
||||||
|
PersistLastReads();
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deliberate join cancels any earlier /leave exclusion
|
||||||
|
UpdateServerConfig(server =>
|
||||||
|
server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -983,14 +1387,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)
|
||||||
@@ -1048,11 +1477,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.
|
||||||
@@ -1070,29 +1573,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");
|
||||||
@@ -1148,20 +1705,63 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
private void SaveServerToConfig(ConnectDialogResult result)
|
private void SaveServerToConfig(ConnectDialogResult result)
|
||||||
{
|
{
|
||||||
var savedServer = new SavedServer
|
// Update the existing entry in place (never replace it) — the per-server entry also
|
||||||
|
// carries cached room keys, left channels, and last-read markers that must survive.
|
||||||
|
var config = ConfigManager.Load();
|
||||||
|
var server = config.SavedServers.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.Url, result.ServerUrl, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (server is null)
|
||||||
{
|
{
|
||||||
Name = new Uri(result.ServerUrl).Host,
|
server = new SavedServer { Name = new Uri(result.ServerUrl).Host, Url = result.ServerUrl };
|
||||||
Url = result.ServerUrl,
|
config.SavedServers.Add(server);
|
||||||
Username = result.Username,
|
}
|
||||||
RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null,
|
|
||||||
RememberMe = result.RememberMe,
|
server.Username = result.Username;
|
||||||
LastConnected = DateTimeOffset.Now
|
server.RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null;
|
||||||
};
|
server.RememberMe = result.RememberMe;
|
||||||
ConfigManager.SaveServer(savedServer);
|
server.LastConnected = DateTimeOffset.Now;
|
||||||
_config = ConfigManager.Load();
|
|
||||||
|
ConfigManager.Save(config);
|
||||||
|
_config = config;
|
||||||
Log.Information("Connected successfully to {Url}", result.ServerUrl);
|
Log.Information("Connected successfully to {Url}", result.ServerUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mutates the current server's config entry and persists it. No-op when not
|
||||||
|
/// authenticated or the server isn't saved.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateServerConfig(Action<SavedServer> mutate)
|
||||||
|
{
|
||||||
|
var url = _conn.Api?.BaseUrl;
|
||||||
|
if (url is null) return;
|
||||||
|
|
||||||
|
var config = ConfigManager.Load();
|
||||||
|
var server = config.SavedServers.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (server is null) return;
|
||||||
|
|
||||||
|
mutate(server);
|
||||||
|
ConfigManager.Save(config);
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the in-memory last-read message ids to the current server's config entry,
|
||||||
|
/// so unread/mention state can be reconstructed on the next connect.
|
||||||
|
/// </summary>
|
||||||
|
private void PersistLastReads()
|
||||||
|
{
|
||||||
|
var lastReads = _messageManager.LastReadIds;
|
||||||
|
if (lastReads.Count == 0) return;
|
||||||
|
|
||||||
|
UpdateServerConfig(server =>
|
||||||
|
{
|
||||||
|
foreach (var (channel, id) in lastReads)
|
||||||
|
server.LastReadMessages[channel] = id.ToString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void ClearSavedToken(string serverUrl)
|
private void ClearSavedToken(string serverUrl)
|
||||||
{
|
{
|
||||||
var config = ConfigManager.Load();
|
var config = ConfigManager.Load();
|
||||||
|
|||||||
@@ -13,10 +13,15 @@ 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;
|
||||||
|
public event Func<Task>? OnRoomInfo;
|
||||||
public event Func<string, Task>? OnSetAvatar;
|
public event Func<string, Task>? OnSetAvatar;
|
||||||
public event Func<string, string?, Task>? OnKickUser;
|
public event Func<string, string?, Task>? OnKickUser;
|
||||||
public event Func<string, string?, Task>? OnBanUser;
|
public event Func<string, string?, Task>? OnBanUser;
|
||||||
@@ -47,13 +52,18 @@ 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(),
|
||||||
|
"meta" or "info" => await HandleMeta(),
|
||||||
"kick" => await HandleKick(args),
|
"kick" => await HandleKick(args),
|
||||||
"ban" => await HandleBan(args),
|
"ban" => await HandleBan(args),
|
||||||
"unban" => await HandleUnban(args),
|
"unban" => await HandleUnban(args),
|
||||||
@@ -126,7 +136,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 +173,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 +226,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +275,13 @@ public class CommandHandler
|
|||||||
return new CommandResult(true);
|
return new CommandResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleMeta()
|
||||||
|
{
|
||||||
|
if (OnRoomInfo is not null)
|
||||||
|
await OnRoomInfo();
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<CommandResult> HandleQuit()
|
private async Task<CommandResult> HandleQuit()
|
||||||
{
|
{
|
||||||
if (OnQuit is not null)
|
if (OnQuit is not null)
|
||||||
@@ -339,14 +396,23 @@ 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
|
||||||
|
/meta - Show room info (size, messages, users, created, id)
|
||||||
Moderation:
|
Moderation:
|
||||||
/kick <user> [reason] - Kick a user (Mod+)
|
/kick <user> [reason] - Kick a user (Mod+)
|
||||||
/ban <user> [reason] - Ban a user (Admin+)
|
/ban <user> [reason] - Ban a user (Admin+)
|
||||||
|
|||||||
@@ -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,25 @@ 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; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Channels the user explicitly left with /leave. Excluded from the automatic
|
||||||
|
/// join-all-channels pass at connect until the user joins them again.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> LeftChannels { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last message the user has read per channel (message id as string), persisted so
|
||||||
|
/// unread counts, @mention highlights, and the "new messages" marker survive restarts.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> LastReadMessages { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AccountPreset
|
public class AccountPreset
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ public static class ConfigManager
|
|||||||
|
|
||||||
private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
|
private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
|
||||||
|
|
||||||
|
// Load-mutate-save cycles run from both the UI thread and background tasks
|
||||||
|
// (token refresh, room keys, last-read checkpoints) — serialize file access.
|
||||||
|
private static readonly Lock FileLock = new();
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
@@ -17,54 +21,66 @@ public static class ConfigManager
|
|||||||
|
|
||||||
public static ClientConfig Load()
|
public static ClientConfig Load()
|
||||||
{
|
{
|
||||||
try
|
lock (FileLock)
|
||||||
{
|
{
|
||||||
if (!File.Exists(ConfigPath))
|
try
|
||||||
return new ClientConfig();
|
{
|
||||||
|
if (!File.Exists(ConfigPath))
|
||||||
|
return new ClientConfig();
|
||||||
|
|
||||||
var json = File.ReadAllText(ConfigPath);
|
var json = File.ReadAllText(ConfigPath);
|
||||||
return JsonSerializer.Deserialize<ClientConfig>(json, JsonOptions) ?? new ClientConfig();
|
return JsonSerializer.Deserialize<ClientConfig>(json, JsonOptions) ?? new ClientConfig();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return new ClientConfig();
|
return new ClientConfig();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Save(ClientConfig config)
|
public static void Save(ClientConfig config)
|
||||||
{
|
{
|
||||||
try
|
lock (FileLock)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(ConfigDir);
|
try
|
||||||
var json = JsonSerializer.Serialize(config, JsonOptions);
|
{
|
||||||
File.WriteAllText(ConfigPath, json);
|
Directory.CreateDirectory(ConfigDir);
|
||||||
}
|
var json = JsonSerializer.Serialize(config, JsonOptions);
|
||||||
catch
|
File.WriteAllText(ConfigPath, json);
|
||||||
{
|
}
|
||||||
// Silently fail — config save is best-effort
|
catch
|
||||||
|
{
|
||||||
|
// Silently fail — config save is best-effort
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SaveServer(SavedServer server)
|
public static void SaveServer(SavedServer server)
|
||||||
{
|
{
|
||||||
var config = Load();
|
lock (FileLock)
|
||||||
var existing = config.SavedServers.FindIndex(s =>
|
{
|
||||||
string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase));
|
var config = Load();
|
||||||
|
var existing = config.SavedServers.FindIndex(s =>
|
||||||
|
string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (existing >= 0)
|
if (existing >= 0)
|
||||||
config.SavedServers[existing] = server;
|
config.SavedServers[existing] = server;
|
||||||
else
|
else
|
||||||
config.SavedServers.Add(server);
|
config.SavedServers.Add(server);
|
||||||
|
|
||||||
Save(config);
|
Save(config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RemoveServer(string url)
|
public static void RemoveServer(string url)
|
||||||
{
|
{
|
||||||
var config = Load();
|
lock (FileLock)
|
||||||
config.SavedServers.RemoveAll(s =>
|
{
|
||||||
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
var config = Load();
|
||||||
|
config.SavedServers.RemoveAll(s =>
|
||||||
|
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
Save(config);
|
Save(config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,10 +126,24 @@ try
|
|||||||
var theme = ThemeManager.GetTheme(config.ActiveTheme);
|
var theme = ThemeManager.GetTheme(config.ActiveTheme);
|
||||||
ThemeManager.ApplyTheme(theme);
|
ThemeManager.ApplyTheme(theme);
|
||||||
|
|
||||||
using var orchestrator = new AppOrchestrator(app, config);
|
var orchestrator = new AppOrchestrator(app, config);
|
||||||
|
|
||||||
app.Run(orchestrator.MainWindow);
|
app.Run(orchestrator.MainWindow);
|
||||||
|
|
||||||
|
// Capture any confirmed update before tearing anything down, then restore the console.
|
||||||
|
var pendingUpdate = orchestrator.PendingUpdate;
|
||||||
app.Dispose();
|
app.Dispose();
|
||||||
|
|
||||||
|
// Apply the update on a clean console: the TUI has released it, so the updater can extract
|
||||||
|
// and restart the process without deadlocking against the alternate-screen buffer. This call
|
||||||
|
// ends by starting the new version and calling Environment.Exit, so it does not return.
|
||||||
|
if (pendingUpdate is not null)
|
||||||
|
{
|
||||||
|
Log.Information("Applying confirmed update after shutdown");
|
||||||
|
await pendingUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
orchestrator.Dispose();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,54 @@ 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>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches a channel's human-facing metadata (message count, unique posters, estimated
|
||||||
|
/// size, created date, room id) for the <c>/meta</c> command. Returns null if it doesn't exist.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedGetAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/meta");
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
return null;
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<ChannelMetaDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,13 @@ namespace EchoHub.Client.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result of a successful connection, returned to AppOrchestrator for UI updates.
|
/// Result of a successful connection, returned to AppOrchestrator for UI updates.
|
||||||
|
/// <paramref name="Histories"/> holds the initial history of every auto-joined channel
|
||||||
|
/// (keyed by channel name, always including the default channel).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal record ConnectResult(
|
internal record ConnectResult(
|
||||||
LoginResponse Login,
|
LoginResponse Login,
|
||||||
List<ChannelDto> Channels,
|
List<ChannelDto> Channels,
|
||||||
List<MessageDto> DefaultHistory);
|
Dictionary<string, List<MessageDto>> Histories);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking.
|
/// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking.
|
||||||
@@ -24,6 +26,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 +34,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,29 +105,60 @@ 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();
|
||||||
|
|
||||||
var channels = await _apiClient.GetChannelsAsync();
|
var channels = await _apiClient.GetChannelsAsync();
|
||||||
onStatus("Connected");
|
|
||||||
|
|
||||||
// Join default channel + fetch history
|
// Join default channel + fetch history
|
||||||
|
onStatus("Joining channels...");
|
||||||
_joinedChannels.Clear();
|
_joinedChannels.Clear();
|
||||||
_joinedChannels.Add(HubConstants.DefaultChannel);
|
_joinedChannels.Add(HubConstants.DefaultChannel);
|
||||||
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
|
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
|
||||||
|
|
||||||
List<MessageDto> history = [];
|
var histories = new Dictionary<string, List<MessageDto>>(StringComparer.OrdinalIgnoreCase);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
|
histories[HubConstants.DefaultChannel] = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// History might not be available
|
// History might not be available
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ConnectResult(loginResponse, channels, history);
|
// Auto-join every other channel the server lists for this user (public +
|
||||||
|
// prior memberships) so message events — unread counts, @mentions — flow for
|
||||||
|
// all of them, not just channels opened this session. Channels the user left
|
||||||
|
// with /leave stay out until rejoined; protected channels we can't enter
|
||||||
|
// silently (no cached membership) are skipped, never prompted for.
|
||||||
|
var leftChannels = FindServer(ConfigManager.Load(), info.ServerUrl)?.LeftChannels ?? [];
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
if (channel.Name.Equals(HubConstants.DefaultChannel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
if (leftChannels.Contains(channel.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var outcome = await _connection.JoinChannelAsync(channel.Name);
|
||||||
|
_joinedChannels.Add(channel.Name);
|
||||||
|
histories[channel.Name] = outcome.History;
|
||||||
|
}
|
||||||
|
catch (ChannelPasswordRequiredException)
|
||||||
|
{
|
||||||
|
// First-time protected channel — joining stays a manual, prompted action
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Auto-join failed for #{Channel}", channel.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus("Connected");
|
||||||
|
return new ConnectResult(loginResponse, channels, histories);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -156,6 +191,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
_apiClient?.Dispose();
|
_apiClient?.Dispose();
|
||||||
_apiClient = null;
|
_apiClient = null;
|
||||||
_joinedChannels.Clear();
|
_joinedChannels.Clear();
|
||||||
|
_roomKeys.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -169,11 +205,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)
|
||||||
@@ -222,11 +268,19 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
|
|
||||||
foreach (var channel in channels)
|
foreach (var channel in channels)
|
||||||
{
|
{
|
||||||
_joinedChannels.Add(channel);
|
// One channel gone bad (deleted, membership revoked) must not stop the rest
|
||||||
await _connection.JoinChannelAsync(channel);
|
try
|
||||||
|
{
|
||||||
|
await _connection.JoinChannelAsync(channel);
|
||||||
|
_joinedChannels.Add(channel);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Rejoin failed for #{Channel}", channel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.Information("Rejoined {Count} channel(s) after reconnect", channels.Count);
|
Log.Information("Rejoined {Count} channel(s) after reconnect", _joinedChannels.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SignalR Event Wiring ──────────────────────────────────────────────
|
// ── SignalR Event Wiring ──────────────────────────────────────────────
|
||||||
@@ -254,8 +308,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
if (_apiClient?.RefreshToken is null) return;
|
if (_apiClient?.RefreshToken is null) return;
|
||||||
var config = ConfigManager.Load();
|
var config = ConfigManager.Load();
|
||||||
var server = config.SavedServers.FirstOrDefault(s =>
|
var server = FindServer(config, _apiClient.BaseUrl);
|
||||||
string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (server is not null && server.RememberMe)
|
if (server is not null && server.RememberMe)
|
||||||
{
|
{
|
||||||
server.RefreshToken = _apiClient.RefreshToken;
|
server.RefreshToken = _apiClient.RefreshToken;
|
||||||
@@ -263,6 +316,10 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SavedServer? FindServer(ClientConfig config, string url) =>
|
||||||
|
config.SavedServers.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
// ── Dispose ───────────────────────────────────────────────────────────
|
// ── Dispose ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -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,7 +177,10 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
using AlwaysUpToDate;
|
using AlwaysUpToDate;
|
||||||
|
|
||||||
using EchoHub.Client.UI.Dialogs;
|
using EchoHub.Client.UI.Dialogs;
|
||||||
@@ -15,9 +17,17 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
|
|
||||||
private readonly Updater _updater;
|
private readonly Updater _updater;
|
||||||
private readonly IApplication _app;
|
private readonly IApplication _app;
|
||||||
private UpdateProgressDialog? _progressDialog;
|
private string? _pendingVersion;
|
||||||
|
private bool _applying;
|
||||||
|
private UpdateStep _lastStep = (UpdateStep)(-1);
|
||||||
private bool _manualCheck;
|
private bool _manualCheck;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set when the user confirms an update. The host runs this <b>after</b> the Terminal.Gui
|
||||||
|
/// main loop has exited and the console is restored, so the library's in-place restart doesn't
|
||||||
|
/// deadlock against a TUI that still owns the console.
|
||||||
|
/// </summary>
|
||||||
|
public Func<Task>? PendingUpdate { get; private set; }
|
||||||
|
|
||||||
public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
||||||
|
|
||||||
@@ -40,7 +50,6 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task CheckNowAsync()
|
public async Task CheckNowAsync()
|
||||||
{
|
{
|
||||||
_manualCheck = true;
|
_manualCheck = true;
|
||||||
@@ -54,67 +63,98 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnUpdateAvailable(string version, string changelogUrl)
|
private void OnUpdateAvailable(string version, string changelogUrl)
|
||||||
{
|
{
|
||||||
Log.Information("Update available: v{Version}", version);
|
Log.Information("Update available: v{Version}", version);
|
||||||
|
|
||||||
_app.Invoke(() =>
|
_app.Invoke(() =>
|
||||||
{
|
{
|
||||||
var confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
|
var confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
|
||||||
|
if (!confirmed)
|
||||||
|
return;
|
||||||
|
|
||||||
if (confirmed)
|
// Defer the actual download/extract/restart to after the TUI is torn down.
|
||||||
{
|
// Running it under the live main loop lets the library restart the process while
|
||||||
_progressDialog = new UpdateProgressDialog(_app, version);
|
// this one still holds the console in raw/alternate-screen mode — the two processes
|
||||||
|
// then deadlock over the console (the "stuck at N/N extracting" hang).
|
||||||
_ = Task.Run(async () =>
|
_pendingVersion = version;
|
||||||
{
|
PendingUpdate = ApplyUpdateAsync;
|
||||||
// Create backup before the update starts
|
_app.RequestStop();
|
||||||
try
|
|
||||||
{
|
|
||||||
_progressDialog?.UpdateProgress(0f, "Creating backup...");
|
|
||||||
UpdateBackupService.CreateBackup();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Failed to create pre-update backup");
|
|
||||||
|
|
||||||
var proceed = false;
|
|
||||||
proceed = MessageBox.Query(
|
|
||||||
_app,
|
|
||||||
"Backup Warning",
|
|
||||||
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
|
|
||||||
"Continue", "Cancel") == 0;
|
|
||||||
|
|
||||||
if (!proceed)
|
|
||||||
{
|
|
||||||
_progressDialog?.Close();
|
|
||||||
_progressDialog = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_progressDialog?.UpdateProgress(0f, "Downloading update...");
|
|
||||||
await _updater.UpdateAsync();
|
|
||||||
});
|
|
||||||
|
|
||||||
_progressDialog.Show();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
|
/// <summary>
|
||||||
|
/// Runs the update on a plain console (invoked by the host after the main loop exits).
|
||||||
|
/// Ends by restarting the app and exiting the process, or restoring the backup on failure.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ApplyUpdateAsync()
|
||||||
{
|
{
|
||||||
var fraction = progressPercentage.HasValue ? (float)(progressPercentage.Value / 100.0) : 0f;
|
_applying = true;
|
||||||
var statusText = $"{step}: {itemsProcessed}/{totalItems ?? 0} ({progressPercentage ?? 0:F0}%)";
|
|
||||||
|
|
||||||
if (!progressPercentage.HasValue)
|
// The TUI restored the console on shutdown; make sure the block-glyph bar renders.
|
||||||
|
try { Console.OutputEncoding = Encoding.UTF8; } catch { /* redirected/non-interactive */ }
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine($"Updating EchoHub to v{_pendingVersion}...");
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
statusText = $"{step}...";
|
Console.WriteLine("Creating backup...");
|
||||||
|
UpdateBackupService.CreateBackup();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to create pre-update backup");
|
||||||
|
Console.WriteLine($"Warning: could not create a backup ({ex.Message}). Continuing without one.");
|
||||||
}
|
}
|
||||||
|
|
||||||
_progressDialog?.UpdateProgress(fraction, statusText);
|
Console.WriteLine("Downloading update...");
|
||||||
|
await _updater.UpdateAsync(); // download → extract → restart → Environment.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const int BarWidth = 28;
|
||||||
|
|
||||||
|
private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
|
||||||
|
{
|
||||||
|
// Before the TUI is torn down (i.e. during a check) there is no progress surface; the
|
||||||
|
// real work happens headless after shutdown, so draw a progress bar on the console.
|
||||||
|
if (!_applying)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Finish the previous step's line so each step keeps its completed bar.
|
||||||
|
if (step != _lastStep)
|
||||||
|
{
|
||||||
|
if (_lastStep != (UpdateStep)(-1))
|
||||||
|
Console.WriteLine();
|
||||||
|
_lastStep = step;
|
||||||
|
}
|
||||||
|
|
||||||
|
var label = Humanize(step);
|
||||||
|
|
||||||
|
if (progressPercentage is { } percent)
|
||||||
|
{
|
||||||
|
var pct = (int)Math.Clamp(Math.Round(percent), 0, 100);
|
||||||
|
var filled = pct * BarWidth / 100;
|
||||||
|
var bar = new string('█', filled) + new string('░', BarWidth - filled); // █ / ░
|
||||||
|
Console.Write($"\r {label,-13} [{bar}] {pct,3}% ");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Steps with no measurable total (verifying, restarting): show an indeterminate marker.
|
||||||
|
Console.Write($"\r {label,-13} working... ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Humanize(UpdateStep step) => step switch
|
||||||
|
{
|
||||||
|
UpdateStep.Downloading => "Downloading",
|
||||||
|
UpdateStep.VerifyingChecksum => "Verifying",
|
||||||
|
UpdateStep.Extracting => "Extracting",
|
||||||
|
UpdateStep.CleaningUp => "Cleaning up",
|
||||||
|
UpdateStep.Restarting => "Restarting",
|
||||||
|
_ => step.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
private void OnUpdateStarted(string version)
|
private void OnUpdateStarted(string version)
|
||||||
{
|
{
|
||||||
Log.Information("Update started: v{Version}", version);
|
Log.Information("Update started: v{Version}", version);
|
||||||
@@ -135,40 +175,40 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
private void OnException(Exception exception)
|
private void OnException(Exception exception)
|
||||||
{
|
{
|
||||||
Log.Error(exception, "Update failed");
|
Log.Error(exception, "Update failed");
|
||||||
_app.Invoke(() =>
|
|
||||||
|
// Headless failure (post-shutdown): report and offer rollback on the console.
|
||||||
|
if (_applying)
|
||||||
{
|
{
|
||||||
_progressDialog?.Close();
|
Console.Error.WriteLine();
|
||||||
_progressDialog = null;
|
Console.Error.WriteLine($"Update failed: {exception.Message}");
|
||||||
|
|
||||||
if (UpdateBackupService.BackupExists())
|
if (UpdateBackupService.BackupExists())
|
||||||
{
|
{
|
||||||
var restore = MessageBox.Query(
|
Console.WriteLine("Restoring the previous version...");
|
||||||
_app,
|
try
|
||||||
"Update Failed",
|
|
||||||
$"The update failed: {exception.Message}\n\n"
|
|
||||||
+ "A backup of the previous version is available.\nRestore now? (The app will restart.)",
|
|
||||||
"Restore", "Cancel");
|
|
||||||
|
|
||||||
if (restore == 0)
|
|
||||||
{
|
{
|
||||||
try
|
UpdateBackupService.RestoreBackup(); // calls Environment.Exit(0)
|
||||||
{
|
}
|
||||||
UpdateBackupService.RestoreBackup();
|
catch (Exception restoreEx)
|
||||||
// RestoreBackup calls Environment.Exit(0)
|
{
|
||||||
}
|
Log.Error(restoreEx, "Backup restoration failed");
|
||||||
catch (Exception restoreEx)
|
Console.Error.WriteLine($"Restore failed: {restoreEx.Message}. Re-download EchoHub to recover.");
|
||||||
{
|
Environment.Exit(1);
|
||||||
Log.Error(restoreEx, "Backup restoration failed");
|
|
||||||
MessageBox.ErrorQuery(_app, "Restore Failed",
|
|
||||||
$"Could not restore backup: {restoreEx.Message}\n\nYou may need to re-download the application.", "OK");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
MessageBox.ErrorQuery(_app, "Update Failed",
|
Console.Error.WriteLine("No backup available. Re-download EchoHub if it no longer starts.");
|
||||||
$"The update failed: {exception.Message}\n\nYou may need to re-download the application.", "OK");
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure during a check while the TUI is still running.
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
MessageBox.ErrorQuery(_app, "Update Check Failed",
|
||||||
|
$"Could not check for updates: {exception.Message}", "OK");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,5 +219,6 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
_updater.UpdateStarted -= OnUpdateStarted;
|
_updater.UpdateStarted -= OnUpdateStarted;
|
||||||
_updater.NoUpdateAvailable -= OnNoUpdateAvailable;
|
_updater.NoUpdateAvailable -= OnNoUpdateAvailable;
|
||||||
_updater.OnException -= OnException;
|
_updater.OnException -= OnException;
|
||||||
|
_updater.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ public class Theme
|
|||||||
public ThemeColors Menu { get; set; } = new();
|
public ThemeColors Menu { get; set; } = new();
|
||||||
public ThemeColors Dialog { get; set; } = new();
|
public ThemeColors Dialog { get; set; } = new();
|
||||||
public ThemeColors Status { get; set; } = new();
|
public ThemeColors Status { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Colors for the main-window frame borders (and their titles). Null falls back
|
||||||
|
/// to <see cref="Base"/>. Lets themes tone borders down independently of text —
|
||||||
|
/// e.g. the transparent themes use a dim gray for a subtler, glassy look.
|
||||||
|
/// Supports hex values ("#6E6E6E") as well as named colors.
|
||||||
|
/// </summary>
|
||||||
|
public ThemeColors? Border { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ThemeColors
|
public class ThemeColors
|
||||||
|
|||||||
@@ -442,6 +442,55 @@ public static class ThemeManager
|
|||||||
Background = "None",
|
Background = "None",
|
||||||
FocusForeground = "Gray",
|
FocusForeground = "Gray",
|
||||||
FocusBackground = "None"
|
FocusBackground = "None"
|
||||||
|
},
|
||||||
|
// Dim, grayish borders — bright white frames fight the glassy transparent look
|
||||||
|
Border = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "#6E6E6E",
|
||||||
|
Background = "None",
|
||||||
|
FocusForeground = "#8A8A8A",
|
||||||
|
FocusBackground = "None"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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"
|
||||||
|
},
|
||||||
|
// Softer gray borders against light terminal backgrounds
|
||||||
|
Border = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "#8F8F8F",
|
||||||
|
Background = "None",
|
||||||
|
FocusForeground = "#6E6E6E",
|
||||||
|
FocusBackground = "None"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -449,6 +498,7 @@ public static class ThemeManager
|
|||||||
[
|
[
|
||||||
DefaultTheme,
|
DefaultTheme,
|
||||||
TransparentTheme,
|
TransparentTheme,
|
||||||
|
TransparentLightTheme,
|
||||||
ClassicTheme,
|
ClassicTheme,
|
||||||
LightTheme,
|
LightTheme,
|
||||||
HackerTheme,
|
HackerTheme,
|
||||||
@@ -512,6 +562,8 @@ public static class ThemeManager
|
|||||||
SchemeManager.AddScheme("Base", BuildColorScheme(theme.Base));
|
SchemeManager.AddScheme("Base", BuildColorScheme(theme.Base));
|
||||||
SchemeManager.AddScheme("Menu", BuildColorScheme(theme.Menu));
|
SchemeManager.AddScheme("Menu", BuildColorScheme(theme.Menu));
|
||||||
SchemeManager.AddScheme("Dialog", BuildColorScheme(theme.Dialog));
|
SchemeManager.AddScheme("Dialog", BuildColorScheme(theme.Dialog));
|
||||||
|
// Frame borders/titles; themes without an explicit Border section keep Base
|
||||||
|
SchemeManager.AddScheme("Border", BuildColorScheme(theme.Border ?? theme.Base));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SaveTheme(Theme theme)
|
public static void SaveTheme(Theme theme)
|
||||||
@@ -541,7 +593,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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ public static partial class ChatColors
|
|||||||
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None);
|
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None);
|
||||||
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
|
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
|
||||||
|
|
||||||
|
/// <summary>The dim vertical rail (│) separating the nick column from message text.</summary>
|
||||||
|
public static readonly Attribute RailAttr = new(new Color(95, 95, 95), Color.None);
|
||||||
|
|
||||||
|
/// <summary>Horizontal date-separator rules (── Wed, Jul 16 ──…).</summary>
|
||||||
|
public static readonly Attribute DateRuleAttr = new(new Color(120, 120, 120), Color.None);
|
||||||
|
|
||||||
|
/// <summary>The irssi-style "new messages" unread marker rule.</summary>
|
||||||
|
public static readonly Attribute UnreadMarkerAttr = new(new Color(230, 140, 60), Color.None);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split text around @mentions and #channels, giving each the appropriate accent color.
|
/// Split text around @mentions and #channels, giving each the appropriate accent color.
|
||||||
/// Non-special text uses the provided default color.
|
/// Non-special text uses the provided default color.
|
||||||
|
|||||||
@@ -18,11 +18,30 @@ 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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Colored segments to prepend on continuation lines instead of plain spaces
|
||||||
|
/// (e.g. the nick-column rail " │ "). When set, takes
|
||||||
|
/// precedence over <see cref="ContinuationIndent"/>.
|
||||||
|
/// </summary>
|
||||||
|
public List<ChatSegment>? ContinuationPrefixSegments { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, this line is a horizontal separator rule (date change, unread marker).
|
||||||
|
/// The view regenerates it to the current viewport width instead of word-wrapping.
|
||||||
|
/// </summary>
|
||||||
|
public string? RuleLabel { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Color for a rule line; null falls back to <see cref="ChatColors.DateRuleAttr"/>.</summary>
|
||||||
|
public Attribute? RuleAttr { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True for the "new messages" unread-marker rule so it can be removed on channel switch.</summary>
|
||||||
|
public bool IsUnreadMarker { get; set; }
|
||||||
|
|
||||||
public ChatLine(string plainText)
|
public ChatLine(string plainText)
|
||||||
{
|
{
|
||||||
Segments = [new ChatSegment(plainText, null)];
|
Segments = [new ChatSegment(plainText, null)];
|
||||||
@@ -43,9 +62,14 @@ public partial class ChatLine
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
|
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
|
||||||
{
|
{
|
||||||
if (width <= 0 || TextLength <= width)
|
// Rules are regenerated to viewport width by the view; never word-wrap them.
|
||||||
|
if (RuleLabel is not null || width <= 0 || TextLength <= width)
|
||||||
return [this];
|
return [this];
|
||||||
|
|
||||||
|
var prefixSegments = ContinuationPrefixSegments;
|
||||||
|
if (prefixSegments is not null)
|
||||||
|
continuationIndent = prefixSegments.Sum(s => s.Text.GetColumns());
|
||||||
|
|
||||||
var tokens = new List<(string grapheme, Attribute? color)>();
|
var tokens = new List<(string grapheme, Attribute? color)>();
|
||||||
foreach (var segment in Segments)
|
foreach (var segment in Segments)
|
||||||
foreach (var g in GraphemeHelper.GetGraphemes(segment.Text))
|
foreach (var g in GraphemeHelper.GetGraphemes(segment.Text))
|
||||||
@@ -91,8 +115,13 @@ public partial class ChatLine
|
|||||||
}
|
}
|
||||||
|
|
||||||
var segments = new List<ChatSegment>();
|
var segments = new List<ChatSegment>();
|
||||||
if (!firstLine && continuationIndent > 0)
|
if (!firstLine)
|
||||||
segments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
{
|
||||||
|
if (prefixSegments is not null)
|
||||||
|
segments.AddRange(prefixSegments);
|
||||||
|
else if (continuationIndent > 0)
|
||||||
|
segments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
||||||
|
}
|
||||||
|
|
||||||
// Rebuild segments by grouping consecutive same-color tokens.
|
// Rebuild segments by grouping consecutive same-color tokens.
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -120,13 +149,15 @@ public partial class ChatLine
|
|||||||
return [this];
|
return [this];
|
||||||
|
|
||||||
// Propagate metadata to all wrapped lines so they remain clickable
|
// Propagate metadata to all wrapped lines so they remain clickable
|
||||||
|
// and keep the mention highlight across continuation lines
|
||||||
foreach (var wrapped in results)
|
foreach (var wrapped in results)
|
||||||
{
|
{
|
||||||
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;
|
||||||
|
wrapped.IsMention = IsMention;
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -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(" ");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Client.UI.Helpers;
|
using EchoHub.Client.UI.Helpers;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
@@ -15,8 +16,20 @@ namespace EchoHub.Client.UI.Chat;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ChatMessageManager
|
public sealed class ChatMessageManager
|
||||||
{
|
{
|
||||||
|
/// <summary>Columns reserved for the right-aligned nick column (WeeChat-style).</summary>
|
||||||
|
public const int NickColWidth = 12;
|
||||||
|
|
||||||
|
/// <summary>Columns before message text starts: "HH:mm " + nick column + " │ ".</summary>
|
||||||
|
public const int ContentIndentCols = 6 + NickColWidth + 3;
|
||||||
|
|
||||||
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
||||||
private readonly Dictionary<string, int> _channelUnread = [];
|
private readonly Dictionary<string, int> _channelUnread = [];
|
||||||
|
private readonly Dictionary<string, DateTime> _channelLastDate = [];
|
||||||
|
private readonly HashSet<string> _markedChannels = [];
|
||||||
|
private readonly Dictionary<string, Guid> _markerAnchor = [];
|
||||||
|
private readonly HashSet<string> _mentionChannels = [];
|
||||||
|
private readonly Dictionary<string, Guid> _lastRead = [];
|
||||||
|
private readonly Dictionary<string, Guid> _channelNewestId = [];
|
||||||
|
|
||||||
private string _currentUser = string.Empty;
|
private string _currentUser = string.Empty;
|
||||||
private string _currentChannel = string.Empty;
|
private string _currentChannel = string.Empty;
|
||||||
@@ -33,7 +46,18 @@ public sealed class ChatMessageManager
|
|||||||
public string CurrentChannel
|
public string CurrentChannel
|
||||||
{
|
{
|
||||||
get => _currentChannel;
|
get => _currentChannel;
|
||||||
set => _currentChannel = value;
|
set
|
||||||
|
{
|
||||||
|
if (_currentChannel == value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Leaving a channel consumes its "new messages" marker so the next
|
||||||
|
// unread burst gets a fresh one (irssi behavior), and everything
|
||||||
|
// visible up to now counts as read.
|
||||||
|
RemoveUnreadMarker(_currentChannel);
|
||||||
|
MarkRead(_currentChannel);
|
||||||
|
_currentChannel = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string CurrentUser => _currentUser;
|
public string CurrentUser => _currentUser;
|
||||||
@@ -57,10 +81,27 @@ public sealed class ChatMessageManager
|
|||||||
public void ClearUnread(string channelName)
|
public void ClearUnread(string channelName)
|
||||||
{
|
{
|
||||||
_channelUnread[channelName] = 0;
|
_channelUnread[channelName] = 0;
|
||||||
|
_mentionChannels.Remove(channelName);
|
||||||
|
MarkRead(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last message the user has read per channel — persisted by the orchestrator so
|
||||||
|
/// unread/mention state can be seeded from history on the next connect.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, Guid> LastReadIds => _lastRead;
|
||||||
|
|
||||||
|
private void MarkRead(string channelName)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(channelName) && _channelNewestId.TryGetValue(channelName, out var newest))
|
||||||
|
_lastRead[channelName] = newest;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
|
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
|
||||||
|
|
||||||
|
/// <summary>Channels with an unread @mention of the current user (cleared by <see cref="ClearUnread"/>).</summary>
|
||||||
|
public IReadOnlySet<string> MentionChannels => _mentionChannels;
|
||||||
|
|
||||||
// ── Mutations ────────────────────────────────────────────────────
|
// ── Mutations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -75,19 +116,39 @@ public sealed class ChatMessageManager
|
|||||||
_channelMessages[message.ChannelName] = messages;
|
_channelMessages[message.ChannelName] = messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Day boundary → horizontal date rule
|
||||||
|
var msgDate = message.SentAt.ToLocalTime().Date;
|
||||||
|
if (!_channelLastDate.TryGetValue(message.ChannelName, out var lastDate) || lastDate != msgDate)
|
||||||
|
{
|
||||||
|
messages.Add(DateRule(msgDate));
|
||||||
|
_channelLastDate[message.ChannelName] = msgDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isCurrent = message.ChannelName == _currentChannel;
|
||||||
|
_channelNewestId[message.ChannelName] = message.Id;
|
||||||
|
if (isCurrent)
|
||||||
|
_lastRead[message.ChannelName] = message.Id;
|
||||||
|
|
||||||
|
// First unread message in an inactive channel → "new messages" marker,
|
||||||
|
// anchored to this message so a history reload can re-place it
|
||||||
|
if (!isCurrent && _markedChannels.Add(message.ChannelName))
|
||||||
|
{
|
||||||
|
messages.Add(UnreadMarkerRule());
|
||||||
|
_markerAnchor[message.ChannelName] = message.Id;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
messages.Add(line);
|
messages.Add(line);
|
||||||
|
|
||||||
if (message.ChannelName == _currentChannel)
|
if (!isCurrent)
|
||||||
{
|
|
||||||
MessagesChanged?.Invoke(message.ChannelName);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
_channelUnread.TryGetValue(message.ChannelName, out var count);
|
_channelUnread.TryGetValue(message.ChannelName, out var count);
|
||||||
_channelUnread[message.ChannelName] = count + 1;
|
_channelUnread[message.ChannelName] = count + 1;
|
||||||
MessagesChanged?.Invoke(message.ChannelName);
|
if (lines.Any(l => l.IsMention))
|
||||||
|
_mentionChannels.Add(message.ChannelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MessagesChanged?.Invoke(message.ChannelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -101,24 +162,20 @@ public sealed class ChatMessageManager
|
|||||||
_channelMessages[channelName] = messages;
|
_channelMessages[channelName] = messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
var time = DateTimeOffset.Now.ToString("HH:mm");
|
var time = FormatTime(DateTimeOffset.Now);
|
||||||
var textLines = text.Split('\n');
|
var textLines = text.Split('\n');
|
||||||
|
|
||||||
messages.Add(new ChatLine(
|
var header = SystemHeaderSegments(time);
|
||||||
[
|
header.Add(new(textLines[0].TrimEnd('\r'), ChatColors.SystemAttr));
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
messages.Add(new ChatLine(header) { ContinuationPrefixSegments = RailPrefix() });
|
||||||
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
|
|
||||||
]));
|
|
||||||
|
|
||||||
var indent = new string(' ', $"[{time}] ** ".Length);
|
|
||||||
for (int i = 1; i < textLines.Length; i++)
|
for (int i = 1; i < textLines.Length; i++)
|
||||||
{
|
{
|
||||||
var line = textLines[i].TrimEnd('\r');
|
var line = textLines[i].TrimEnd('\r');
|
||||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
messages.Add(new ChatLine(
|
var segments = RailPrefix();
|
||||||
[
|
segments.Add(new(line, ChatColors.SystemAttr));
|
||||||
new($"{indent}{line}", ChatColors.SystemAttr)
|
messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() });
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
@@ -130,19 +187,16 @@ 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 = FormatTime(DateTimeOffset.Now);
|
||||||
var segments = new List<ChatSegment>
|
var segments = SystemHeaderSegments(time);
|
||||||
{
|
segments.Add(new($"{username} is now {status}", ChatColors.SystemAttr));
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new($"** {username} is now {status}", ChatColors.SystemAttr)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
{
|
{
|
||||||
messages = [];
|
messages = [];
|
||||||
_channelMessages[channelName] = messages;
|
_channelMessages[channelName] = messages;
|
||||||
}
|
}
|
||||||
messages.Add(new ChatLine(segments));
|
messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() });
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
MessagesChanged?.Invoke(channelName);
|
MessagesChanged?.Invoke(channelName);
|
||||||
@@ -169,6 +223,9 @@ public sealed class ChatMessageManager
|
|||||||
if (_channelMessages.TryGetValue(channelName, out var messages))
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
{
|
{
|
||||||
messages.Clear();
|
messages.Clear();
|
||||||
|
_channelLastDate.Remove(channelName);
|
||||||
|
_markedChannels.Remove(channelName);
|
||||||
|
_markerAnchor.Remove(channelName);
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
MessagesChanged?.Invoke(channelName);
|
MessagesChanged?.Invoke(channelName);
|
||||||
}
|
}
|
||||||
@@ -176,14 +233,88 @@ public sealed class ChatMessageManager
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load historical messages into a channel, replacing any existing messages.
|
/// Load historical messages into a channel, replacing any existing messages.
|
||||||
|
/// When <paramref name="lastReadId"/> is given (persisted from a previous session),
|
||||||
|
/// messages after it seed the unread count, @mention highlight, and the
|
||||||
|
/// "new messages" marker — so activity that happened while offline still lights up.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void LoadHistory(string channelName, List<MessageDto> messages)
|
public void LoadHistory(string channelName, List<MessageDto> messages, Guid? lastReadId = null)
|
||||||
{
|
{
|
||||||
var formatted = messages.SelectMany(FormatMessage).ToList();
|
var formatted = FormatWithDateRules(messages, out var lastDate);
|
||||||
|
|
||||||
|
if (messages.Count > 0)
|
||||||
|
_channelNewestId[channelName] = messages[^1].Id;
|
||||||
|
|
||||||
|
// Re-place the "new messages" marker at its anchor — channel selection
|
||||||
|
// reloads history, which would otherwise wipe the marker right when the
|
||||||
|
// user switches in to read the unread backlog.
|
||||||
|
if (_markedChannels.Contains(channelName))
|
||||||
|
{
|
||||||
|
var anchorIdx = _markerAnchor.TryGetValue(channelName, out var anchorId)
|
||||||
|
? formatted.FindIndex(l => l.MessageId == anchorId)
|
||||||
|
: -1;
|
||||||
|
if (anchorIdx >= 0)
|
||||||
|
{
|
||||||
|
formatted.Insert(anchorIdx, UnreadMarkerRule());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Anchor fell outside the fetched history window — drop the marker
|
||||||
|
_markedChannels.Remove(channelName);
|
||||||
|
_markerAnchor.Remove(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (lastReadId is { } lastRead && messages.Count > 0)
|
||||||
|
{
|
||||||
|
SeedUnreadFromHistory(channelName, messages, formatted, lastRead);
|
||||||
|
}
|
||||||
|
|
||||||
_channelMessages[channelName] = formatted;
|
_channelMessages[channelName] = formatted;
|
||||||
|
|
||||||
|
if (lastDate is { } date)
|
||||||
|
_channelLastDate[channelName] = date;
|
||||||
|
else
|
||||||
|
_channelLastDate.Remove(channelName);
|
||||||
|
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconstructs unread state from a persisted last-read message id: places the
|
||||||
|
/// "new messages" marker before the first unread message and, for inactive
|
||||||
|
/// channels, seeds the unread count and @mention highlight. A last-read id that
|
||||||
|
/// is no longer inside the fetched window treats the whole window as unread.
|
||||||
|
/// </summary>
|
||||||
|
private void SeedUnreadFromHistory(string channelName, List<MessageDto> messages,
|
||||||
|
List<ChatLine> formatted, Guid lastReadId)
|
||||||
|
{
|
||||||
|
// FindIndex miss (-1 → 0) means the last-read message is older than the fetched
|
||||||
|
// window: everything in the window counts as unread.
|
||||||
|
var firstUnread = messages.FindIndex(m => m.Id == lastReadId) + 1;
|
||||||
|
if (firstUnread >= messages.Count)
|
||||||
|
return; // everything read
|
||||||
|
|
||||||
|
var anchor = messages[firstUnread];
|
||||||
|
var lineIdx = formatted.FindIndex(l => l.MessageId == anchor.Id);
|
||||||
|
if (lineIdx < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
formatted.Insert(lineIdx, UnreadMarkerRule());
|
||||||
|
_markedChannels.Add(channelName);
|
||||||
|
_markerAnchor[channelName] = anchor.Id;
|
||||||
|
|
||||||
|
// The active channel shows the marker but is being read right now —
|
||||||
|
// badges and mention highlights are only for background channels.
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
MessagesChanged?.Invoke(channelName);
|
return;
|
||||||
|
|
||||||
|
_channelUnread[channelName] = messages.Count - firstUnread;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(_currentUser))
|
||||||
|
{
|
||||||
|
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||||
|
if (messages.Skip(firstUnread).Any(m => Regex.IsMatch(m.Content, pattern, RegexOptions.IgnoreCase)))
|
||||||
|
_mentionChannels.Add(channelName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -200,14 +331,23 @@ public sealed class ChatMessageManager
|
|||||||
.Select(l => l.MessageId!.Value)
|
.Select(l => l.MessageId!.Value)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
var newLines = olderMessages
|
var fresh = olderMessages.Where(m => !existingIds.Contains(m.Id)).ToList();
|
||||||
.Where(m => !existingIds.Contains(m.Id))
|
var newLines = FormatWithDateRules(fresh, out var lastBatchDate);
|
||||||
.SelectMany(FormatMessage)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (newLines.Count == 0)
|
if (newLines.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// The buffer's leading date rule is redundant when the prepended batch
|
||||||
|
// ends on the same day — the batch already carries that day's rule.
|
||||||
|
if (lastBatchDate is { } batchDate
|
||||||
|
&& existing.Count > 0
|
||||||
|
&& existing[0].RuleLabel is { } label
|
||||||
|
&& !existing[0].IsUnreadMarker
|
||||||
|
&& label == DateRuleLabel(batchDate))
|
||||||
|
{
|
||||||
|
existing.RemoveAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
existing.InsertRange(0, newLines);
|
existing.InsertRange(0, newLines);
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
@@ -226,6 +366,12 @@ public sealed class ChatMessageManager
|
|||||||
{
|
{
|
||||||
_channelMessages.Clear();
|
_channelMessages.Clear();
|
||||||
_channelUnread.Clear();
|
_channelUnread.Clear();
|
||||||
|
_channelLastDate.Clear();
|
||||||
|
_markedChannels.Clear();
|
||||||
|
_markerAnchor.Clear();
|
||||||
|
_mentionChannels.Clear();
|
||||||
|
_lastRead.Clear();
|
||||||
|
_channelNewestId.Clear();
|
||||||
_currentChannel = string.Empty;
|
_currentChannel = string.Empty;
|
||||||
_currentUser = string.Empty;
|
_currentUser = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -234,74 +380,91 @@ 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 = FormatTime(message.SentAt);
|
||||||
var senderName = message.SenderUsername + ":";
|
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor)
|
||||||
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
?? NickColorHelper.GetAttribute(message.SenderUsername);
|
||||||
|
|
||||||
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))
|
|
||||||
{
|
var header = HeaderSegments(time, message.SenderUsername, senderColor);
|
||||||
foreach (var artLine in message.Content.Split('\n'))
|
header.AddRange(ChatColors.SplitMentions(contentLines[0].TrimEnd('\r')));
|
||||||
|
lines.Add(new ChatLine(header));
|
||||||
|
|
||||||
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
|
{
|
||||||
|
var segments = RailPrefix();
|
||||||
|
segments.AddRange(ChatColors.SplitMentions(contentLines[i].TrimEnd('\r')));
|
||||||
|
lines.Add(new ChatLine(segments));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var summary = attachments.Count switch
|
||||||
|
{
|
||||||
|
0 => " ",
|
||||||
|
1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]",
|
||||||
|
_ => $"[{attachments.Count} attachments]",
|
||||||
|
};
|
||||||
|
var header = HeaderSegments(time, message.SenderUsername, senderColor);
|
||||||
|
header.Add(new(summary, null));
|
||||||
|
lines.Add(new ChatLine(header));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var l in lines)
|
||||||
|
l.ContinuationPrefixSegments = RailPrefix();
|
||||||
|
|
||||||
|
// One block per attachment — every block hangs off the nick-column rail
|
||||||
|
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
|
var segments = RailPrefix();
|
||||||
lines.Add(new ChatLine($" {trimmed}"));
|
if (ChatLine.HasColorTags(trimmed))
|
||||||
|
segments.AddRange(ChatLine.FromColoredText(trimmed).Segments);
|
||||||
|
else
|
||||||
|
segments.Add(new(trimmed, null));
|
||||||
|
lines.Add(new ChatLine(segments));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
lines.Add(AttachmentActionLine(
|
||||||
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(
|
||||||
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(
|
||||||
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, 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)
|
||||||
@@ -310,7 +473,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))
|
||||||
@@ -323,58 +486,146 @@ public sealed class ChatMessageManager
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
|
/// <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 text, Attribute color, AttachmentDto attachment)
|
||||||
{
|
{
|
||||||
var segments = new List<ChatSegment>
|
var segments = RailPrefix();
|
||||||
|
segments.Add(new(text, color));
|
||||||
|
return new ChatLine(segments)
|
||||||
{
|
{
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
AttachmentUrl = attachment.Url,
|
||||||
new(senderName, senderColor),
|
AttachmentFileName = attachment.FileName,
|
||||||
new(suffix, null)
|
AttachmentKind = attachment.Kind,
|
||||||
|
ContinuationPrefixSegments = RailPrefix(),
|
||||||
};
|
};
|
||||||
return new ChatLine(segments);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
|
/// <summary>
|
||||||
|
/// Leading segments of a message header line: dim "HH:mm ", the right-aligned
|
||||||
|
/// nick column, and the " │ " rail. Message text follows at <see cref="ContentIndentCols"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static List<ChatSegment> HeaderSegments(string time, string nick, Attribute? nickColor) =>
|
||||||
|
[
|
||||||
|
new($"{time} ", ChatColors.TimestampAttr),
|
||||||
|
new(PadNick(nick), nickColor),
|
||||||
|
new(" │ ", ChatColors.RailAttr),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Header variant for system/status lines: "--" in the nick column.</summary>
|
||||||
|
private static List<ChatSegment> SystemHeaderSegments(string time) =>
|
||||||
|
[
|
||||||
|
new($"{time} ", ChatColors.TimestampAttr),
|
||||||
|
new(PadNick("--"), ChatColors.TimestampAttr),
|
||||||
|
new(" │ ", ChatColors.RailAttr),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indent segments aligning continuation/attachment/embed lines under the message
|
||||||
|
/// text, extending the │ rail. Returns a fresh mutable list each call.
|
||||||
|
/// </summary>
|
||||||
|
private static List<ChatSegment> RailPrefix() =>
|
||||||
|
[
|
||||||
|
new(new string(' ', 6 + NickColWidth + 1), null),
|
||||||
|
new("│ ", ChatColors.RailAttr),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Right-aligns a nick into the fixed nick column, truncating over-long nicks
|
||||||
|
/// with an ellipsis. Grapheme/column aware.
|
||||||
|
/// </summary>
|
||||||
|
internal static string PadNick(string nick)
|
||||||
{
|
{
|
||||||
var segments = new List<ChatSegment>
|
var cols = nick.GetColumns();
|
||||||
|
if (cols > NickColWidth)
|
||||||
{
|
{
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
var sb = new StringBuilder();
|
||||||
new(senderName, senderColor),
|
int used = 0;
|
||||||
new(suffix, suffixColor)
|
foreach (var g in GraphemeHelper.GetGraphemes(nick))
|
||||||
};
|
{
|
||||||
return new ChatLine(segments);
|
var gCols = Math.Max(g.GetColumns(), 1);
|
||||||
|
if (used + gCols > NickColWidth - 1) break;
|
||||||
|
sb.Append(g);
|
||||||
|
used += gCols;
|
||||||
|
}
|
||||||
|
sb.Append('…');
|
||||||
|
nick = sb.ToString();
|
||||||
|
cols = used + 1;
|
||||||
|
}
|
||||||
|
return new string(' ', NickColWidth - cols) + nick;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
|
internal static string DateRuleLabel(DateTime date) => date.ToString("ddd, MMM d yyyy");
|
||||||
|
|
||||||
|
private static ChatLine DateRule(DateTime date)
|
||||||
{
|
{
|
||||||
var segments = new List<ChatSegment>
|
var label = DateRuleLabel(date);
|
||||||
|
return new ChatLine([new($"── {label} ──", ChatColors.DateRuleAttr)])
|
||||||
{
|
{
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
RuleLabel = label,
|
||||||
new(senderName, senderColor),
|
RuleAttr = ChatColors.DateRuleAttr,
|
||||||
};
|
};
|
||||||
segments.AddRange(ChatColors.SplitMentions(suffix));
|
|
||||||
return new ChatLine(segments);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
|
private static ChatLine UnreadMarkerRule() =>
|
||||||
|
new([new("── new messages ──", ChatColors.UnreadMarkerAttr)])
|
||||||
|
{
|
||||||
|
RuleLabel = "new messages",
|
||||||
|
RuleAttr = ChatColors.UnreadMarkerAttr,
|
||||||
|
IsUnreadMarker = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private void RemoveUnreadMarker(string channel)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(channel) || !_markedChannels.Remove(channel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_markerAnchor.Remove(channel);
|
||||||
|
if (_channelMessages.TryGetValue(channel, out var messages))
|
||||||
|
messages.RemoveAll(l => l.IsUnreadMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a chronological batch of messages, inserting a date rule before the
|
||||||
|
/// first message and at every day boundary. Outputs the batch's last local date.
|
||||||
|
/// </summary>
|
||||||
|
private List<ChatLine> FormatWithDateRules(List<MessageDto> messages, out DateTime? lastDate)
|
||||||
|
{
|
||||||
|
var lines = new List<ChatLine>();
|
||||||
|
lastDate = null;
|
||||||
|
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
var date = message.SentAt.ToLocalTime().Date;
|
||||||
|
if (lastDate != date)
|
||||||
|
{
|
||||||
|
lines.Add(DateRule(date));
|
||||||
|
lastDate = date;
|
||||||
|
}
|
||||||
|
lines.AddRange(FormatMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ChatLine> FormatEmbed(EmbedDto embed, int chatWidth)
|
||||||
{
|
{
|
||||||
var lines = new List<ChatLine>();
|
var lines = new List<ChatLine>();
|
||||||
const string border = "\u258f "; // ▏ + space
|
const string border = "\u258f "; // ▏ + space
|
||||||
const int borderCols = 2;
|
const int borderCols = 2;
|
||||||
int indentCols = indent.GetColumns();
|
int textWidth = chatWidth - ContentIndentCols - borderCols;
|
||||||
int textWidth = chatWidth - indentCols - borderCols;
|
|
||||||
if (textWidth < 20) textWidth = 20;
|
if (textWidth < 20) textWidth = 20;
|
||||||
|
|
||||||
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
|
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
|
||||||
|
|
||||||
void AddTextLine(string text, Attribute? color)
|
void AddTextLine(string text, Attribute? color)
|
||||||
{
|
{
|
||||||
lines.Add(new ChatLine(
|
var segments = RailPrefix();
|
||||||
[
|
segments.Add(new ChatSegment(border, borderAttr));
|
||||||
new ChatSegment(indent, null),
|
segments.Add(new ChatSegment(text, color));
|
||||||
new ChatSegment(border, borderAttr),
|
lines.Add(new ChatLine(segments));
|
||||||
new ChatSegment(text, color)
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
@@ -425,6 +676,12 @@ public sealed class ChatMessageManager
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timestamps are compact HH:mm — the calendar day is carried by date rules,
|
||||||
|
// inserted at every local-day boundary. Convert to local first so a message
|
||||||
|
// near midnight lands under the right date rule.
|
||||||
|
private static string FormatTime(DateTimeOffset timestamp) =>
|
||||||
|
timestamp.ToLocalTime().ToString("HH:mm");
|
||||||
|
|
||||||
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,103 @@
|
|||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The MOTD-style splash rendered into the chat pane when no channel is selected —
|
||||||
|
/// a gold-gradient ASCII logo with version and key hints, in the spirit of classic
|
||||||
|
/// IRC client greetings.
|
||||||
|
/// </summary>
|
||||||
|
internal static class WelcomeBanner
|
||||||
|
{
|
||||||
|
// "ECHOHUB" in FIGlet ANSI-Shadow (58 columns)
|
||||||
|
private static readonly string[] BigLogo =
|
||||||
|
[
|
||||||
|
"███████╗ ██████╗██╗ ██╗ ██████╗ ██╗ ██╗██╗ ██╗██████╗ ",
|
||||||
|
"██╔════╝██╔════╝██║ ██║██╔═══██╗██║ ██║██║ ██║██╔══██╗",
|
||||||
|
"█████╗ ██║ ███████║██║ ██║███████║██║ ██║██████╔╝",
|
||||||
|
"██╔══╝ ██║ ██╔══██║██║ ██║██╔══██║██║ ██║██╔══██╗",
|
||||||
|
"███████╗╚██████╗██║ ██║╚██████╔╝██║ ██║╚██████╔╝██████╔╝",
|
||||||
|
"╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Compact box-drawing fallback for narrow panes (21 columns)
|
||||||
|
private static readonly string[] SmallLogo =
|
||||||
|
[
|
||||||
|
"┌─┐┌─┐┬ ┬┌─┐┬ ┬┬ ┬┌┐ ",
|
||||||
|
"├┤ │ ├─┤│ │├─┤│ │├┴┐",
|
||||||
|
"└─┘└─┘┴ ┴└─┘┴ ┴└─┘└─┘",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Vertical gold gradient, bright at the top fading to bronze — matches the
|
||||||
|
// EchoHub brand color used in the status bar.
|
||||||
|
private static readonly Color[] Gradient =
|
||||||
|
[
|
||||||
|
new(255, 215, 105),
|
||||||
|
new(245, 199, 89),
|
||||||
|
new(232, 183, 74),
|
||||||
|
new(216, 165, 60),
|
||||||
|
new(198, 146, 48),
|
||||||
|
new(178, 128, 38),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly Attribute HintKeyAttr = new(new Color(140, 170, 200), Color.None);
|
||||||
|
private static readonly Attribute HintTextAttr = new(new Color(120, 120, 120), Color.None);
|
||||||
|
private static readonly Attribute TaglineAttr = new(new Color(160, 160, 160), Color.None);
|
||||||
|
|
||||||
|
private static readonly (string Key, string Hint)[] Hints =
|
||||||
|
[
|
||||||
|
("Server → Connect", "join a server"),
|
||||||
|
("Ctrl+K ", "search channels & users"),
|
||||||
|
("F2 ", "toggle the users panel"),
|
||||||
|
("/help ", "all commands"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds banner lines centered for the given viewport width.
|
||||||
|
/// </summary>
|
||||||
|
public static List<ChatLine> Build(int width, string version)
|
||||||
|
{
|
||||||
|
var logo = width >= BigLogo[0].GetColumns() + 2 ? BigLogo : SmallLogo;
|
||||||
|
int logoWidth = logo[0].GetColumns();
|
||||||
|
var pad = new string(' ', Math.Max((width - logoWidth) / 2, 0));
|
||||||
|
|
||||||
|
var lines = new List<ChatLine> { new(""), new("") };
|
||||||
|
|
||||||
|
for (int i = 0; i < logo.Length; i++)
|
||||||
|
{
|
||||||
|
// Scale the gradient across however many rows the chosen logo has
|
||||||
|
var color = Gradient[Math.Min(i * Gradient.Length / logo.Length, Gradient.Length - 1)];
|
||||||
|
lines.Add(new ChatLine([
|
||||||
|
new ChatSegment(pad, null),
|
||||||
|
new ChatSegment(logo[i], new Attribute(color, Color.None)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.Add(new ChatLine(""));
|
||||||
|
|
||||||
|
var tagline = $"v{version} — terminal chat with that old IRC soul";
|
||||||
|
lines.Add(Centered(tagline, width, TaglineAttr));
|
||||||
|
lines.Add(new ChatLine(""));
|
||||||
|
|
||||||
|
int hintWidth = Hints.Max(h => h.Key.Length + 2 + h.Hint.Length);
|
||||||
|
var hintPad = new string(' ', Math.Max((width - hintWidth) / 2, 0));
|
||||||
|
foreach (var (key, hint) in Hints)
|
||||||
|
{
|
||||||
|
lines.Add(new ChatLine([
|
||||||
|
new ChatSegment(hintPad, null),
|
||||||
|
new ChatSegment(key, HintKeyAttr),
|
||||||
|
new ChatSegment(" " + hint, HintTextAttr),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatLine Centered(string text, int width, Attribute attr)
|
||||||
|
{
|
||||||
|
var pad = new string(' ', Math.Max((width - text.GetColumns()) / 2, 0));
|
||||||
|
return new ChatLine([new ChatSegment(pad, null), new ChatSegment(text, attr)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
using Terminal.Gui.App;
|
|
||||||
using Terminal.Gui.Views;
|
|
||||||
using Terminal.Gui.ViewBase;
|
|
||||||
|
|
||||||
namespace EchoHub.Client.UI.Dialogs;
|
|
||||||
|
|
||||||
public sealed class UpdateProgressDialog
|
|
||||||
{
|
|
||||||
private readonly Dialog _dialog;
|
|
||||||
private readonly ProgressBar _progressBar;
|
|
||||||
private readonly Label _infoLabel;
|
|
||||||
private readonly IApplication _app;
|
|
||||||
|
|
||||||
public UpdateProgressDialog(IApplication app, string newVersion)
|
|
||||||
{
|
|
||||||
_app = app;
|
|
||||||
|
|
||||||
_dialog = new Dialog { Title = $"Updating to {newVersion}", Width = 50, Height = 10 };
|
|
||||||
|
|
||||||
_infoLabel = new Label
|
|
||||||
{
|
|
||||||
Text = "Preparing update...",
|
|
||||||
X = 1,
|
|
||||||
Y = 1,
|
|
||||||
Width = Dim.Fill(2)
|
|
||||||
};
|
|
||||||
|
|
||||||
_progressBar = new ProgressBar
|
|
||||||
{
|
|
||||||
X = 1,
|
|
||||||
Y = 3,
|
|
||||||
Width = Dim.Fill(2),
|
|
||||||
Fraction = 0f
|
|
||||||
};
|
|
||||||
|
|
||||||
var cancelButton = new Button
|
|
||||||
{
|
|
||||||
Text = "Cancel",
|
|
||||||
X = Pos.Center(),
|
|
||||||
Y = 6
|
|
||||||
};
|
|
||||||
|
|
||||||
_dialog.Add(_infoLabel, _progressBar);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateProgress(float fraction, string statusText)
|
|
||||||
{
|
|
||||||
_progressBar.Fraction = fraction;
|
|
||||||
_infoLabel.Text = statusText;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Show()
|
|
||||||
{
|
|
||||||
_app.Run(_dialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Close()
|
|
||||||
{
|
|
||||||
_app.RequestStop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic per-nick colors for users who haven't picked a nickname color.
|
||||||
|
/// The same nick always maps to the same palette entry (classic IRC client behavior),
|
||||||
|
/// so a busy channel stays scannable without any configuration.
|
||||||
|
/// </summary>
|
||||||
|
public static class NickColorHelper
|
||||||
|
{
|
||||||
|
// Medium-saturation truecolor values chosen to stay readable on both dark and
|
||||||
|
// light backgrounds. Order matters: changing it re-colors everyone.
|
||||||
|
private static readonly Attribute[] Palette =
|
||||||
|
[
|
||||||
|
new(new Color(224, 108, 117), Color.None), // soft red
|
||||||
|
new(new Color(152, 195, 121), Color.None), // green
|
||||||
|
new(new Color(229, 192, 123), Color.None), // sand
|
||||||
|
new(new Color(97, 175, 239), Color.None), // blue
|
||||||
|
new(new Color(198, 120, 221), Color.None), // magenta
|
||||||
|
new(new Color(86, 182, 194), Color.None), // teal
|
||||||
|
new(new Color(255, 160, 122), Color.None), // salmon
|
||||||
|
new(new Color(130, 170, 255), Color.None), // periwinkle
|
||||||
|
new(new Color(195, 232, 141), Color.None), // lime
|
||||||
|
new(new Color(137, 221, 255), Color.None), // sky
|
||||||
|
new(new Color(255, 203, 107), Color.None), // amber
|
||||||
|
new(new Color(240, 130, 170), Color.None), // rose
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable palette index for a nick: case-insensitive FNV-1a over the nick,
|
||||||
|
/// reduced modulo <paramref name="paletteSize"/>. Pure function (no Terminal.Gui
|
||||||
|
/// types) so it is unit-testable without a display driver.
|
||||||
|
/// </summary>
|
||||||
|
public static int GetPaletteIndex(string nick, int paletteSize)
|
||||||
|
{
|
||||||
|
if (paletteSize <= 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
const uint fnvOffset = 2166136261;
|
||||||
|
const uint fnvPrime = 16777619;
|
||||||
|
|
||||||
|
uint hash = fnvOffset;
|
||||||
|
foreach (var ch in nick)
|
||||||
|
{
|
||||||
|
hash ^= char.ToLowerInvariant(ch);
|
||||||
|
hash *= fnvPrime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)(hash % (uint)paletteSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The color attribute for a nick. Used as a fallback when the user has no
|
||||||
|
/// explicit nickname color set.
|
||||||
|
/// </summary>
|
||||||
|
public static Attribute GetAttribute(string nick) =>
|
||||||
|
Palette[GetPaletteIndex(nick, Palette.Length)];
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ 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 readonly HashSet<string> _mentionChannels = [];
|
||||||
private string _activeChannel = string.Empty;
|
private string _activeChannel = string.Empty;
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
@@ -27,14 +29,22 @@ public class ChannelListSource : IListDataSource
|
|||||||
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
|
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
|
||||||
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);
|
||||||
|
private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), 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, IReadOnlySet<string>? mentionChannels = 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);
|
||||||
|
_mentionChannels.Clear();
|
||||||
|
if (mentionChannels is not null)
|
||||||
|
_mentionChannels.UnionWith(mentionChannels);
|
||||||
_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 +67,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
|
||||||
@@ -76,12 +87,18 @@ public class ChannelListSource : IListDataSource
|
|||||||
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
|
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
|
||||||
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
||||||
|
|
||||||
listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr));
|
// Mentions escalate above plain unread: the whole entry turns orange
|
||||||
|
var hasMention = _mentionChannels.Contains(name);
|
||||||
|
var nameAttr = isActive ? ActiveAttr
|
||||||
|
: hasMention ? MentionAttr
|
||||||
|
: hasUnread ? UnreadAttr
|
||||||
|
: NormalAttr;
|
||||||
|
listView.SetAttribute(Resolve(nameAttr));
|
||||||
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
|
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
|
||||||
|
|
||||||
if (hasUnread)
|
if (hasUnread)
|
||||||
{
|
{
|
||||||
listView.SetAttribute(Resolve(BadgeAttr));
|
listView.SetAttribute(Resolve(hasMention ? MentionAttr : BadgeAttr));
|
||||||
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,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;
|
||||||
@@ -39,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) ?? "?";
|
||||||
@@ -50,12 +53,17 @@ public sealed partial class MainWindow : Runnable
|
|||||||
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 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"
|
||||||
];
|
];
|
||||||
@@ -63,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";
|
||||||
@@ -148,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>
|
||||||
@@ -229,6 +248,8 @@ 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.Scrolled += OnMessageListVerticalScrollBarScrolled;
|
||||||
_messageList.VerticalScrollBar.Visible = true;
|
_messageList.VerticalScrollBar.Visible = true;
|
||||||
|
|
||||||
@@ -238,7 +259,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
// Bottom input area
|
// Bottom input area
|
||||||
_inputFrame = new FrameView
|
_inputFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete \u2502 Ctrl+K=search",
|
Title = DefaultInputTitle,
|
||||||
X = 22,
|
X = 22,
|
||||||
Y = Pos.Bottom(_chatFrame),
|
Y = Pos.Bottom(_chatFrame),
|
||||||
Width = Dim.Fill(UsersPanelWidth),
|
Width = Dim.Fill(UsersPanelWidth),
|
||||||
@@ -253,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);
|
||||||
@@ -294,6 +319,12 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_statusLabel.DrawingContent += OnStatusBarDrawContent;
|
_statusLabel.DrawingContent += OnStatusBarDrawContent;
|
||||||
Add(_statusLabel);
|
Add(_statusLabel);
|
||||||
|
|
||||||
|
// Rounded borders for a softer, modern frame look
|
||||||
|
channelsFrame.BorderStyle = LineStyle.Rounded;
|
||||||
|
_chatFrame.BorderStyle = LineStyle.Rounded;
|
||||||
|
_inputFrame.BorderStyle = LineStyle.Rounded;
|
||||||
|
_usersFrame.BorderStyle = LineStyle.Rounded;
|
||||||
|
|
||||||
// Apply our custom color schemes to all views
|
// Apply our custom color schemes to all views
|
||||||
ApplyColorSchemes();
|
ApplyColorSchemes();
|
||||||
|
|
||||||
@@ -306,6 +337,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.
|
||||||
@@ -314,6 +366,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
var baseScheme = SchemeManager.GetScheme("Base");
|
var baseScheme = SchemeManager.GetScheme("Base");
|
||||||
var menuScheme = SchemeManager.GetScheme("Menu");
|
var menuScheme = SchemeManager.GetScheme("Menu");
|
||||||
|
var borderScheme = SchemeManager.GetScheme("Border") ?? baseScheme;
|
||||||
|
|
||||||
if (baseScheme is not null)
|
if (baseScheme is not null)
|
||||||
{
|
{
|
||||||
@@ -324,6 +377,12 @@ public sealed partial class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel)
|
if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel)
|
||||||
sub.SetScheme(baseScheme);
|
sub.SetScheme(baseScheme);
|
||||||
|
|
||||||
|
// Frame borders (and their titles) take the theme's border colors, so
|
||||||
|
// themes can tone them down independently of text (e.g. transparent
|
||||||
|
// themes use dim gray instead of eye-catching white)
|
||||||
|
if (sub is FrameView frame && borderScheme is not null)
|
||||||
|
frame.Border?.SetScheme(borderScheme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,19 +502,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();
|
||||||
@@ -488,6 +554,133 @@ 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)
|
private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> e)
|
||||||
{
|
{
|
||||||
if (_messageList.VerticalScrollBar.Value == 0)
|
if (_messageList.VerticalScrollBar.Value == 0)
|
||||||
@@ -523,7 +716,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;
|
||||||
@@ -540,6 +735,51 @@ public sealed partial class MainWindow : Runnable
|
|||||||
ShowSearchDialog();
|
ShowSearchDialog();
|
||||||
e.Handled = true;
|
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;
|
||||||
@@ -553,6 +793,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;
|
||||||
@@ -562,9 +823,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>
|
||||||
@@ -604,6 +871,20 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0);
|
_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()
|
||||||
{
|
{
|
||||||
var newWidth = _messageList.Viewport.Width;
|
var newWidth = _messageList.Viewport.Width;
|
||||||
@@ -645,6 +926,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
RefreshMessages();
|
RefreshMessages();
|
||||||
else
|
else
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
|
|
||||||
|
// Background-channel activity feeds the status bar's Act segment
|
||||||
|
_statusLabel.SetNeedsDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnHistoryPrepended(string channelName)
|
private void OnHistoryPrepended(string channelName)
|
||||||
@@ -675,11 +959,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();
|
||||||
}
|
}
|
||||||
@@ -687,13 +974,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();
|
||||||
@@ -707,6 +1004,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,6 +1032,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
public void UpdateStatusBar(string status)
|
public void UpdateStatusBar(string status)
|
||||||
{
|
{
|
||||||
_connectionStatus = status;
|
_connectionStatus = status;
|
||||||
|
UpdateSpinner();
|
||||||
_statusLabel.SetNeedsDraw();
|
_statusLabel.SetNeedsDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,6 +1040,38 @@ public sealed partial class MainWindow : Runnable
|
|||||||
private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.None);
|
private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.None);
|
||||||
private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.None);
|
private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.None);
|
||||||
private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.None);
|
private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.None);
|
||||||
|
private static readonly Attribute StatusActivityAttr = new(new Color(80, 200, 220), Color.None);
|
||||||
|
private static readonly Attribute StatusMentionAttr = new(new Color(230, 140, 60), Color.None);
|
||||||
|
|
||||||
|
// Braille spinner shown while the connection is in a transitional state
|
||||||
|
private static readonly string[] SpinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
private object? _spinnerToken;
|
||||||
|
private int _spinnerFrame;
|
||||||
|
|
||||||
|
private bool IsTransitionalStatus => _connectionStatus is not ("Connected" or "Disconnected");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the spinner timer when entering a transitional connection state
|
||||||
|
/// (Connecting, Reconnecting, …); the timer stops itself once the state settles.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateSpinner()
|
||||||
|
{
|
||||||
|
if (!IsTransitionalStatus || _spinnerToken is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_spinnerToken = _app.AddTimeout(TimeSpan.FromMilliseconds(120), () =>
|
||||||
|
{
|
||||||
|
if (!IsTransitionalStatus)
|
||||||
|
{
|
||||||
|
_spinnerToken = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_spinnerFrame = (_spinnerFrame + 1) % SpinnerFrames.Length;
|
||||||
|
_statusLabel.SetNeedsDraw();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void OnStatusBarDrawContent(object? sender, DrawEventArgs e)
|
private void OnStatusBarDrawContent(object? sender, DrawEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -772,13 +1103,15 @@ public sealed partial class MainWindow : Runnable
|
|||||||
Write(" EchoHub", Resolve(StatusBrandAttr));
|
Write(" EchoHub", Resolve(StatusBrandAttr));
|
||||||
Write($" \u2502 v{AppVersion} \u2502 ", normalAttr);
|
Write($" \u2502 v{AppVersion} \u2502 ", normalAttr);
|
||||||
|
|
||||||
// Connection state with color
|
// Connection state with color; transitional states get an animated spinner
|
||||||
var statusAttr = _connectionStatus switch
|
var statusAttr = _connectionStatus switch
|
||||||
{
|
{
|
||||||
"Connected" => StatusConnectedAttr,
|
"Connected" => StatusConnectedAttr,
|
||||||
"Disconnected" => StatusDisconnectedAttr,
|
"Disconnected" => StatusDisconnectedAttr,
|
||||||
_ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc.
|
_ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc.
|
||||||
};
|
};
|
||||||
|
if (IsTransitionalStatus)
|
||||||
|
Write($"{SpinnerFrames[_spinnerFrame]} ", statusAttr);
|
||||||
Write(_connectionStatus, Resolve(statusAttr));
|
Write(_connectionStatus, Resolve(statusAttr));
|
||||||
|
|
||||||
// User
|
// User
|
||||||
@@ -792,9 +1125,33 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activity segment (irssi-style): channels with unread messages,
|
||||||
|
// mention-channels highlighted in orange
|
||||||
|
var activity = _messageManager.GetUnreadCounts()
|
||||||
|
.Where(kv => kv.Value > 0)
|
||||||
|
.Select(kv => kv.Key)
|
||||||
|
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
if (activity.Count > 0)
|
||||||
|
{
|
||||||
|
const int maxShown = 4;
|
||||||
|
Write(" \u2502 Act: ", normalAttr);
|
||||||
|
var mentions = _messageManager.MentionChannels;
|
||||||
|
for (int i = 0; i < activity.Count && i < maxShown; i++)
|
||||||
|
{
|
||||||
|
if (i > 0)
|
||||||
|
Write(",", normalAttr);
|
||||||
|
Write($"#{activity[i]}", mentions.Contains(activity[i]) ? StatusMentionAttr : StatusActivityAttr);
|
||||||
|
}
|
||||||
|
if (activity.Count > maxShown)
|
||||||
|
Write($" +{activity.Count - maxShown}", normalAttr);
|
||||||
|
}
|
||||||
|
|
||||||
// Fill remaining space
|
// Fill remaining space
|
||||||
_statusLabel.SetAttribute(normalAttr);
|
_statusLabel.SetAttribute(normalAttr);
|
||||||
while (col < width)
|
while (col < width)
|
||||||
@@ -855,6 +1212,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";
|
||||||
@@ -874,26 +1232,51 @@ 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 width = _messageList.Viewport.Width;
|
||||||
|
|
||||||
|
// Update cached width when viewport reports a valid value;
|
||||||
|
// fall back to last known width if viewport hasn't been laid out yet.
|
||||||
|
if (width > 0)
|
||||||
|
_lastChatWidth = width;
|
||||||
|
else
|
||||||
|
width = _lastChatWidth;
|
||||||
|
|
||||||
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
||||||
if (messages is not null)
|
if (messages is not null)
|
||||||
{
|
{
|
||||||
var width = _messageList.Viewport.Width;
|
|
||||||
|
|
||||||
// Update cached width when viewport reports a valid value;
|
|
||||||
// fall back to last known width if viewport hasn't been laid out yet.
|
|
||||||
if (width > 0)
|
|
||||||
_lastChatWidth = width;
|
|
||||||
else
|
|
||||||
width = _lastChatWidth;
|
|
||||||
|
|
||||||
var source = new ChatListSource();
|
var source = new ChatListSource();
|
||||||
|
|
||||||
if (width > 0)
|
if (width > 0)
|
||||||
{
|
{
|
||||||
foreach (var line in messages)
|
foreach (var line in messages)
|
||||||
source.AddRange(line.Wrap(width, line.ContinuationIndent));
|
{
|
||||||
|
if (line.RuleLabel is not null)
|
||||||
|
source.Add(ExpandRule(line, width));
|
||||||
|
else
|
||||||
|
source.AddRange(line.Wrap(width, line.ContinuationIndent));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -906,16 +1289,36 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_messageList.Source = new ChatListSource();
|
// No channel selected — greet with the MOTD-style splash
|
||||||
|
var source = new ChatListSource();
|
||||||
|
if (width > 0)
|
||||||
|
source.AddRange(WelcomeBanner.Build(width, AppVersion));
|
||||||
|
_messageList.Source = source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regenerates a separator rule (date change / unread marker) to span the
|
||||||
|
/// current viewport width: "── label ────────…".
|
||||||
|
/// </summary>
|
||||||
|
private static ChatLine ExpandRule(ChatLine line, int width)
|
||||||
|
{
|
||||||
|
var attr = line.RuleAttr ?? ChatColors.DateRuleAttr;
|
||||||
|
var label = line.RuleLabel!;
|
||||||
|
var tailLen = Math.Max(width - 4 - label.GetColumns() - 1, 2);
|
||||||
|
return new ChatLine([new ChatSegment($"── {label} {new string('─', tailLen)}", attr)])
|
||||||
|
{
|
||||||
|
IsUnreadMarker = line.IsUnreadMarker,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refresh the channel list view, showing unread counts next to channel names.
|
/// Refresh the channel list view, showing unread counts next to channel names.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshChannelList()
|
private void RefreshChannelList()
|
||||||
{
|
{
|
||||||
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel);
|
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
|
||||||
|
_channelProtected, _messageManager.MentionChannels);
|
||||||
_channelList.Source = _channelListSource;
|
_channelList.Source = _channelListSource;
|
||||||
|
|
||||||
// Restore selection to current channel
|
// Restore selection to current channel
|
||||||
@@ -991,8 +1394,11 @@ public sealed partial class MainWindow : Runnable
|
|||||||
var text = roleTag.Length > 0
|
var text = roleTag.Length > 0
|
||||||
? $"{statusIcon} {roleTag} {name}"
|
? $"{statusIcon} {roleTag} {name}"
|
||||||
: $"{statusIcon} {name}";
|
: $"{statusIcon} {name}";
|
||||||
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
|
// Fall back to the deterministic per-nick palette so user-list colors
|
||||||
return (text, nameColor, u.Username);
|
// match the same user's messages in chat.
|
||||||
|
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor)
|
||||||
|
?? NickColorHelper.GetAttribute(u.Username);
|
||||||
|
return (text, (Attribute?)nameColor, u.Username);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
_usersListSource.Update(displayItems);
|
_usersListSource.Update(displayItems);
|
||||||
|
|||||||
@@ -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,6 +9,7 @@ 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;
|
||||||
|
|||||||
@@ -6,17 +6,24 @@ 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<ChannelMetaDto?> GetChannelMetaAsync(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,7 +10,7 @@ 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
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ namespace EchoHub.Core.Contracts;
|
|||||||
|
|
||||||
public interface IMessageEncryptionService
|
public interface IMessageEncryptionService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Prefix marking transport/at-rest encrypted content. <see cref="Decrypt"/> is a
|
||||||
|
/// pass-through for values without it.
|
||||||
|
/// </summary>
|
||||||
|
const string CiphertextPrefix = "$ENC$v1$";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether database content should be encrypted at rest (server setting).
|
/// Whether database content should be encrypted at rest (server setting).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -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,58 @@ 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>
|
||||||
|
/// Human-facing summary of a channel (the <c>/meta</c> command). For encrypted channels the
|
||||||
|
/// server still knows these figures — count, timestamps, and stored blob sizes — even though it
|
||||||
|
/// cannot read the content itself. <see cref="EstimatedSizeBytes"/> is the sum of stored
|
||||||
|
/// attachment blob sizes plus message text length, so it is an estimate, not an exact on-disk total.
|
||||||
|
/// </summary>
|
||||||
|
public record ChannelMetaDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? Topic,
|
||||||
|
bool IsEncrypted,
|
||||||
|
bool IsProtected,
|
||||||
|
int MessageCount,
|
||||||
|
int UniqueUserCount,
|
||||||
|
long EstimatedSizeBytes,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
/// <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
|
||||||
{
|
{
|
||||||
@@ -16,8 +16,16 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
|
|
||||||
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
||||||
{
|
{
|
||||||
// Decrypt content for IRC clients (they can't handle app-layer encryption)
|
// Decrypt content and attachment previews for IRC clients (they can't handle
|
||||||
var decryptedMessage = message with { Content = _encryption.Decrypt(message.Content) };
|
// app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched;
|
||||||
|
// the formatter drops previews that are still ciphertext.
|
||||||
|
var decryptedMessage = message with
|
||||||
|
{
|
||||||
|
Content = _encryption.Decrypt(message.Content),
|
||||||
|
Attachments = message.Attachments?
|
||||||
|
.Select(a => a with { AsciiPreview = _encryption.DecryptNullable(a.AsciiPreview) })
|
||||||
|
.ToList(),
|
||||||
|
};
|
||||||
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage);
|
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage);
|
||||||
|
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Core.Security;
|
||||||
|
|
||||||
namespace EchoHub.Server.Irc;
|
namespace EchoHub.Server.Irc;
|
||||||
|
|
||||||
@@ -18,45 +20,63 @@ 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 && !IsCiphertext(attachment.AsciiPreview))
|
||||||
|
{
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when a preview is still encrypted — transport ($ENC$v1$) if a broadcast path
|
||||||
|
/// forgot to decrypt it, or E2E room ciphertext ($RC1$) the server cannot decrypt.
|
||||||
|
/// Emitting it would flood IRC clients with a multi-KB base64 blob.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsCiphertext(string text) =>
|
||||||
|
text.StartsWith(IMessageEncryptionService.CiphertextPrefix, StringComparison.Ordinal)
|
||||||
|
|| text.StartsWith(RoomCrypto.CiphertextPrefix, StringComparison.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
|
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin-configurable upload limits, bound from the <c>Uploads</c> configuration section.
|
||||||
|
/// Sizes are expressed in megabytes in configuration; the <c>*Bytes</c> accessors convert them
|
||||||
|
/// for enforcement. Every default mirrors <see cref="HubConstants"/> so an absent or partial
|
||||||
|
/// <c>Uploads</c> section preserves the historical built-in limits.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UploadLimits
|
||||||
|
{
|
||||||
|
public int MaxFileSizeMB { get; init; } = HubConstants.MaxFileSizeBytes / (1024 * 1024);
|
||||||
|
public int MaxImageSizeMB { get; init; } = HubConstants.MaxImageSizeBytes / (1024 * 1024);
|
||||||
|
public int MaxAudioSizeMB { get; init; } = HubConstants.MaxAudioFileSizeBytes / (1024 * 1024);
|
||||||
|
public int MaxAvatarSizeMB { get; init; } = HubConstants.MaxAvatarSizeBytes / (1024 * 1024);
|
||||||
|
public int MaxAttachmentsPerMessage { get; init; } = HubConstants.MaxAttachmentsPerMessage;
|
||||||
|
|
||||||
|
public long MaxFileSizeBytes => (long)MaxFileSizeMB * 1024 * 1024;
|
||||||
|
public long MaxImageSizeBytes => (long)MaxImageSizeMB * 1024 * 1024;
|
||||||
|
public long MaxAudioSizeBytes => (long)MaxAudioSizeMB * 1024 * 1024;
|
||||||
|
public long MaxAvatarSizeBytes => (long)MaxAvatarSizeMB * 1024 * 1024;
|
||||||
|
|
||||||
|
/// <summary>Maximum accepted size for a single attachment of the given kind.</summary>
|
||||||
|
public long MaxForKind(AttachmentKind kind) => kind switch
|
||||||
|
{
|
||||||
|
AttachmentKind.Image => MaxImageSizeBytes,
|
||||||
|
AttachmentKind.Audio => MaxAudioSizeBytes,
|
||||||
|
_ => MaxFileSizeBytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Absolute ceiling for one message request body (largest file × the attachment cap). Used to
|
||||||
|
/// size the request-body and multipart limits so a configured increase actually takes effect.
|
||||||
|
/// </summary>
|
||||||
|
public long MaxRequestBodyBytes => MaxFileSizeBytes * MaxAttachmentsPerMessage;
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
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.Config;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
@@ -24,6 +28,7 @@ public class ChannelsController : ControllerBase
|
|||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly IChatService _chatService;
|
private readonly IChatService _chatService;
|
||||||
private readonly IMessageEncryptionService _encryption;
|
private readonly IMessageEncryptionService _encryption;
|
||||||
|
private readonly UploadLimits _uploadLimits;
|
||||||
|
|
||||||
public ChannelsController(
|
public ChannelsController(
|
||||||
IChannelService channelService,
|
IChannelService channelService,
|
||||||
@@ -32,7 +37,8 @@ public class ChannelsController : ControllerBase
|
|||||||
ImageToAsciiService asciiService,
|
ImageToAsciiService asciiService,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IChatService chatService,
|
IChatService chatService,
|
||||||
IMessageEncryptionService encryption)
|
IMessageEncryptionService encryption,
|
||||||
|
UploadLimits uploadLimits)
|
||||||
{
|
{
|
||||||
_channelService = channelService;
|
_channelService = channelService;
|
||||||
_db = db;
|
_db = db;
|
||||||
@@ -41,6 +47,7 @@ public class ChannelsController : ControllerBase
|
|||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
|
_uploadLimits = uploadLimits;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -65,7 +72,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 +83,58 @@ 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>
|
||||||
|
/// Human-facing summary of a channel (message count, unique posters, estimated size,
|
||||||
|
/// created date, room id). Available for encrypted channels too — these are metadata the
|
||||||
|
/// server tracks even though it cannot read the messages themselves.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{channel}/meta")]
|
||||||
|
public async Task<IActionResult> GetChannelMeta(string channel)
|
||||||
|
{
|
||||||
|
var meta = await _channelService.GetChannelMetaAsync(channel);
|
||||||
|
if (meta is null)
|
||||||
|
return NotFound(new ErrorResponse($"Channel '{channel}' does not exist."));
|
||||||
|
|
||||||
|
return Ok(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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,12 +165,25 @@ 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>
|
||||||
|
// Request-body and multipart limits are applied at runtime from the configured UploadLimits
|
||||||
|
// (see below) rather than via [RequestSizeLimit]/[RequestFormLimits], which require
|
||||||
|
// compile-time constants and so couldn't honor the "Uploads" configuration section.
|
||||||
|
[HttpPost("{channel}/messages")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
[RequestSizeLimit(HubConstants.MaxFileSizeBytes)]
|
public async Task<IActionResult> SendMessageWithAttachments(string channel, [FromQuery] string? size = null)
|
||||||
[RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)]
|
|
||||||
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
|
|
||||||
{
|
{
|
||||||
|
// Raise this request's body ceiling to the configured maximum before the body is read.
|
||||||
|
var bodySizeFeature = HttpContext.Features.Get<IHttpMaxRequestBodySizeFeature>();
|
||||||
|
if (bodySizeFeature is not null && !bodySizeFeature.IsReadOnly)
|
||||||
|
bodySizeFeature.MaxRequestBodySize = _uploadLimits.MaxRequestBodyBytes;
|
||||||
|
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var usernameClaim = User.FindFirstValue("username");
|
var usernameClaim = User.FindFirstValue("username");
|
||||||
if (userIdClaim is null || usernameClaim is null)
|
if (userIdClaim is null || usernameClaim is null)
|
||||||
@@ -126,80 +199,129 @@ 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 > _uploadLimits.MaxAttachmentsPerMessage)
|
||||||
|
return BadRequest(new ErrorResponse($"A message may carry at most {_uploadLimits.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 > _uploadLimits.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 > _uploadLimits.MaxForKind(kind))
|
||||||
|
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {_uploadLimits.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,
|
||||||
|
};
|
||||||
|
|
||||||
[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 +341,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."));
|
||||||
|
|
||||||
@@ -236,13 +362,13 @@ public class ChannelsController : ControllerBase
|
|||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var contentLength = response.Content.Headers.ContentLength;
|
var contentLength = response.Content.Headers.ContentLength;
|
||||||
if (contentLength > HubConstants.MaxImageSizeBytes)
|
if (contentLength > _uploadLimits.MaxImageSizeBytes)
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
|
return BadRequest(new ErrorResponse($"File size exceeds maximum of {_uploadLimits.MaxImageSizeBytes / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
imageBytes = await response.Content.ReadAsByteArrayAsync();
|
imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
|
|
||||||
if (imageBytes.Length > HubConstants.MaxImageSizeBytes)
|
if (imageBytes.Length > _uploadLimits.MaxImageSizeBytes)
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
|
return BadRequest(new ErrorResponse($"File size exceeds maximum of {_uploadLimits.MaxImageSizeBytes / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
fileName = Path.GetFileName(uri.LocalPath);
|
fileName = Path.GetFileName(uri.LocalPath);
|
||||||
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
||||||
@@ -276,46 +402,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,7 +1,9 @@
|
|||||||
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.Config;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -17,11 +19,13 @@ public class UsersController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
private readonly ImageToAsciiService _asciiService;
|
private readonly ImageToAsciiService _asciiService;
|
||||||
|
private readonly UploadLimits _uploadLimits;
|
||||||
|
|
||||||
public UsersController(IUserService userService, ImageToAsciiService asciiService)
|
public UsersController(IUserService userService, ImageToAsciiService asciiService, UploadLimits uploadLimits)
|
||||||
{
|
{
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
_asciiService = asciiService;
|
_asciiService = asciiService;
|
||||||
|
_uploadLimits = uploadLimits;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{username}/profile")]
|
[HttpGet("{username}/profile")]
|
||||||
@@ -64,8 +68,8 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
var file = Request.Form.Files[0];
|
var file = Request.Form.Files[0];
|
||||||
|
|
||||||
if (file.Length > HubConstants.MaxAvatarSizeBytes)
|
if (file.Length > _uploadLimits.MaxAvatarSizeBytes)
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
|
return BadRequest(new ErrorResponse($"File size exceeds maximum of {_uploadLimits.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ 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.Config;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using EchoHub.Server.Hubs;
|
using EchoHub.Server.Hubs;
|
||||||
using EchoHub.Server.Irc;
|
using EchoHub.Server.Irc;
|
||||||
@@ -101,6 +103,14 @@ while (true)
|
|||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
|
// ── Upload limits (admin-configurable via the "Uploads" section) ─────
|
||||||
|
var uploadLimits = builder.Configuration.GetSection("Uploads").Get<UploadLimits>() ?? new UploadLimits();
|
||||||
|
builder.Services.AddSingleton(uploadLimits);
|
||||||
|
// Raise the multipart form ceiling to match the configured limits; per-endpoint
|
||||||
|
// request-body limits are applied at the action from the same values.
|
||||||
|
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
|
||||||
|
o.MultipartBodyLengthLimit = uploadLimits.MaxRequestBodyBytes);
|
||||||
|
|
||||||
// ── Services ─────────────────────────────────────────────────────────
|
// ── Services ─────────────────────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<JwtTokenService>();
|
builder.Services.AddSingleton<JwtTokenService>();
|
||||||
builder.Services.AddSingleton<PresenceTracker>();
|
builder.Services.AddSingleton<PresenceTracker>();
|
||||||
|
|||||||
@@ -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,77 @@ 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<ChannelMetaDto?> GetChannelMetaAsync(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;
|
||||||
|
|
||||||
|
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
|
||||||
|
|
||||||
|
// Distinct senders that have posted here. Works the same for encrypted channels —
|
||||||
|
// sender identity is metadata the server keeps even when it can't read the messages.
|
||||||
|
var uniqueUsers = await db.Messages
|
||||||
|
.Where(m => m.ChannelId == c.Id)
|
||||||
|
.Select(m => m.SenderUserId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
// Estimated footprint: stored attachment blob sizes + message text length. For encrypted
|
||||||
|
// channels these are the ciphertext sizes, which is the server's real on-disk cost.
|
||||||
|
var attachmentBytes = await db.Messages
|
||||||
|
.Where(m => m.ChannelId == c.Id)
|
||||||
|
.SelectMany(m => m.Attachments)
|
||||||
|
.SumAsync(a => (long?)a.FileSize) ?? 0;
|
||||||
|
var textBytes = await db.Messages
|
||||||
|
.Where(m => m.ChannelId == c.Id)
|
||||||
|
.SumAsync(m => (long?)m.Content.Length) ?? 0;
|
||||||
|
|
||||||
|
return new ChannelMetaDto(
|
||||||
|
c.Id, c.Name, c.Topic,
|
||||||
|
c.WrappedRoomKey != null, c.PasswordHash != null,
|
||||||
|
messageCount, uniqueUsers, attachmentBytes + textBytes, c.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +378,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 +386,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 +405,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);
|
||||||
|
|
||||||
@@ -386,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)
|
||||||
{
|
{
|
||||||
@@ -400,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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace EchoHub.Server.Services;
|
|||||||
|
|
||||||
public class MessageEncryptionService : IMessageEncryptionService
|
public class MessageEncryptionService : IMessageEncryptionService
|
||||||
{
|
{
|
||||||
private const string EncryptionPrefix = "$ENC$v1$";
|
private const string EncryptionPrefix = IMessageEncryptionService.CiphertextPrefix;
|
||||||
private const int NonceSizeBytes = 12;
|
private const int NonceSizeBytes = 12;
|
||||||
private const int TagSizeBytes = 16;
|
private const int TagSizeBytes = 16;
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
"CleanupIntervalHours": 1,
|
"CleanupIntervalHours": 1,
|
||||||
"RetentionDays": 30
|
"RetentionDays": 30
|
||||||
},
|
},
|
||||||
|
"Uploads": {
|
||||||
|
"MaxFileSizeMB": 100,
|
||||||
|
"MaxImageSizeMB": 10,
|
||||||
|
"MaxAudioSizeMB": 10,
|
||||||
|
"MaxAvatarSizeMB": 2,
|
||||||
|
"MaxAttachmentsPerMessage": 10
|
||||||
|
},
|
||||||
"Encryption": {
|
"Encryption": {
|
||||||
"Key": "",
|
"Key": "",
|
||||||
"EncryptDatabase": false
|
"EncryptDatabase": false
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
@@ -459,4 +472,22 @@ public class CommandHandlerTests
|
|||||||
Assert.True(result.Handled);
|
Assert.True(result.Handled);
|
||||||
Assert.True(quitCalled);
|
Assert.True(quitCalled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── /meta ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/meta")]
|
||||||
|
[InlineData("/info")]
|
||||||
|
public async Task HandleAsync_Meta_RaisesRoomInfo(string input)
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var raised = false;
|
||||||
|
handler.OnRoomInfo += () => { raised = true; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
var result = await handler.HandleAsync(input);
|
||||||
|
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.True(raised);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -90,6 +89,27 @@ public class IrcBroadcasterTests
|
|||||||
Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
|
Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_DecryptsAttachmentAsciiPreview()
|
||||||
|
{
|
||||||
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
var attachment = new AttachmentDto(
|
||||||
|
AttachmentKind.Image, "/api/files/abc", "photo.png", 1234,
|
||||||
|
_encryption.Encrypt("line1\nline2"));
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(), _encryption.Encrypt("look at this"), "alice", null, "general",
|
||||||
|
DateTimeOffset.UtcNow, [attachment]);
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains("[Image: photo.png]"));
|
||||||
|
Assert.Contains(output, l => l.Contains("line1"));
|
||||||
|
Assert.Contains(output, l => l.Contains("line2"));
|
||||||
|
Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SendMessage_SkipsSender()
|
public async Task SendMessage_SkipsSender()
|
||||||
{
|
{
|
||||||
@@ -97,8 +117,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 +135,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]
|
||||||
@@ -140,6 +138,19 @@ public class IrcMessageFormatterTests
|
|||||||
Assert.Equal(2, asciiLines.Count);
|
Assert.Equal(2, asciiLines.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("$ENC$v1$abc123$def456")] // transport ciphertext a broadcast path forgot to decrypt
|
||||||
|
[InlineData("$RC1$abc123def456")] // E2E room ciphertext the server cannot decrypt
|
||||||
|
public void FormatMessage_ImageMessage_SkipsCiphertextPreview(string ciphertextPreview)
|
||||||
|
{
|
||||||
|
var msg = CreateImageMessage(ciphertextPreview, "photo.jpg", "https://example.com/photo.jpg");
|
||||||
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
|
// Only the [Image: ...] header line — never the ciphertext blob
|
||||||
|
Assert.Single(lines);
|
||||||
|
Assert.Contains("[Image: photo.jpg]", lines[0]);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_FileMessage_FormatsCorrectly()
|
public void FormatMessage_FileMessage_FormatsCorrectly()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,12 @@ 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 ChannelMetaDto? ChannelMetaToReturn { get; set; }
|
||||||
|
|
||||||
|
public Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName) =>
|
||||||
|
Task.FromResult(ChannelMetaToReturn);
|
||||||
|
|
||||||
|
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,55 @@
|
|||||||
|
using EchoHub.Client.UI.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the deterministic nick→palette-index hash.
|
||||||
|
/// Note: GetAttribute (Terminal.Gui Attribute) is excluded — Terminal.Gui's module
|
||||||
|
/// initializer requires a display driver unavailable in CI.
|
||||||
|
/// </summary>
|
||||||
|
public class NickColorHelperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetPaletteIndex_SameNick_IsStable()
|
||||||
|
{
|
||||||
|
var first = NickColorHelper.GetPaletteIndex("alice", 12);
|
||||||
|
var second = NickColorHelper.GetPaletteIndex("alice", 12);
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPaletteIndex_IsCaseInsensitive()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
NickColorHelper.GetPaletteIndex("Alice", 12),
|
||||||
|
NickColorHelper.GetPaletteIndex("aLICE", 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("alice")]
|
||||||
|
[InlineData("bob")]
|
||||||
|
[InlineData("charlie_long_nickname")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("émile")]
|
||||||
|
public void GetPaletteIndex_AlwaysWithinRange(string nick)
|
||||||
|
{
|
||||||
|
var index = NickColorHelper.GetPaletteIndex(nick, 12);
|
||||||
|
Assert.InRange(index, 0, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPaletteIndex_DistributesAcrossPalette()
|
||||||
|
{
|
||||||
|
// Not a strict uniformity test — just that the hash isn't degenerate
|
||||||
|
var nicks = new[] { "alice", "bob", "carol", "dave", "erin", "frank", "grace", "heidi" };
|
||||||
|
var distinct = nicks.Select(n => NickColorHelper.GetPaletteIndex(n, 12)).Distinct().Count();
|
||||||
|
Assert.True(distinct >= 3, $"Expected at least 3 distinct palette slots, got {distinct}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPaletteIndex_NonPositivePaletteSize_ReturnsZero()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, NickColorHelper.GetPaletteIndex("alice", 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class UploadLimitsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Defaults_MirrorHubConstants()
|
||||||
|
{
|
||||||
|
var limits = new UploadLimits();
|
||||||
|
|
||||||
|
Assert.Equal(HubConstants.MaxFileSizeBytes, limits.MaxFileSizeBytes);
|
||||||
|
Assert.Equal(HubConstants.MaxImageSizeBytes, limits.MaxImageSizeBytes);
|
||||||
|
Assert.Equal(HubConstants.MaxAudioFileSizeBytes, limits.MaxAudioSizeBytes);
|
||||||
|
Assert.Equal(HubConstants.MaxAvatarSizeBytes, limits.MaxAvatarSizeBytes);
|
||||||
|
Assert.Equal(HubConstants.MaxAttachmentsPerMessage, limits.MaxAttachmentsPerMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaxForKind_MapsEachAttachmentKind()
|
||||||
|
{
|
||||||
|
var limits = new UploadLimits
|
||||||
|
{
|
||||||
|
MaxImageSizeMB = 5,
|
||||||
|
MaxAudioSizeMB = 7,
|
||||||
|
MaxFileSizeMB = 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(5L * 1024 * 1024, limits.MaxForKind(AttachmentKind.Image));
|
||||||
|
Assert.Equal(7L * 1024 * 1024, limits.MaxForKind(AttachmentKind.Audio));
|
||||||
|
Assert.Equal(11L * 1024 * 1024, limits.MaxForKind(AttachmentKind.File));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaxRequestBodyBytes_IsFileSizeTimesAttachmentCap()
|
||||||
|
{
|
||||||
|
var limits = new UploadLimits { MaxFileSizeMB = 20, MaxAttachmentsPerMessage = 4 };
|
||||||
|
|
||||||
|
Assert.Equal(20L * 1024 * 1024 * 4, limits.MaxRequestBodyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BoundFromConfiguration_OverridesDefaults()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Uploads:MaxFileSizeMB"] = "250",
|
||||||
|
["Uploads:MaxImageSizeMB"] = "25",
|
||||||
|
["Uploads:MaxAttachmentsPerMessage"] = "3",
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var limits = config.GetSection("Uploads").Get<UploadLimits>()!;
|
||||||
|
|
||||||
|
Assert.Equal(250L * 1024 * 1024, limits.MaxFileSizeBytes);
|
||||||
|
Assert.Equal(25L * 1024 * 1024, limits.MaxImageSizeBytes);
|
||||||
|
Assert.Equal(3, limits.MaxAttachmentsPerMessage);
|
||||||
|
// Unspecified values keep their HubConstants-derived defaults.
|
||||||
|
Assert.Equal(HubConstants.MaxAudioFileSizeBytes, limits.MaxAudioSizeBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user