mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df1bd0119c | ||
|
|
71d82b45fb | ||
|
|
53d0d326cb | ||
|
|
3281064720 | ||
|
|
bb987dda82 | ||
|
|
e71575d920 | ||
|
|
96736d69df | ||
|
|
953e081123 | ||
|
|
b9d099dd73 | ||
|
|
a9519a52cc | ||
|
|
4d25458717 | ||
|
|
584ce45979 | ||
|
|
53927b130c | ||
|
|
ca3c1ec2b5 | ||
|
|
35a4eec749 | ||
|
|
78f18a36ba | ||
|
|
2157884e61 | ||
|
|
4f2cffa372 | ||
|
|
31a0bb8c9f | ||
|
|
e439c8ae72 | ||
|
|
b6c01dab15 | ||
|
|
1f4af9a543 | ||
|
|
b62729dc95 | ||
|
|
aae788028e | ||
|
|
c048b39e42 | ||
|
|
3886e7148c | ||
|
|
b9a80806e1 | ||
|
|
e797ec2542 | ||
|
|
dbf6565d18 | ||
|
|
494dcb46cf |
@@ -6,6 +6,8 @@
|
|||||||
Server__Name=My EchoHub Server
|
Server__Name=My EchoHub Server
|
||||||
Server__Description=A self-hosted EchoHub chat server
|
Server__Description=A self-hosted EchoHub chat server
|
||||||
Server__PublicServer=false
|
Server__PublicServer=false
|
||||||
|
# Registration mode: open (default), invite (codes via /invite, Admin+), or closed.
|
||||||
|
# Server__Registration=open
|
||||||
# Hostnames advertised to the EchoHubSpace directory. Index per entry.
|
# Hostnames advertised to the EchoHubSpace directory. Index per entry.
|
||||||
# Server__PublicHosts__0=echohub.example.com
|
# Server__PublicHosts__0=echohub.example.com
|
||||||
# Server__PublicHosts__1=alias.example.com
|
# Server__PublicHosts__1=alias.example.com
|
||||||
@@ -13,6 +15,8 @@ Server__PublicServer=false
|
|||||||
# Server__Tags__0=community
|
# Server__Tags__0=community
|
||||||
# Server__Tags__1=gaming
|
# Server__Tags__1=gaming
|
||||||
# Server__Admins__0=adminUsername
|
# Server__Admins__0=adminUsername
|
||||||
|
# Override where the EchoHubSpace claim token file is stored (defaults next to the DB).
|
||||||
|
# Server__DirectoryClaimPath=
|
||||||
|
|
||||||
# ── JWT ──────────────────────────────────────────────────────────────
|
# ── JWT ──────────────────────────────────────────────────────────────
|
||||||
# Auto-generated on first run if left empty. Only set if you need a stable secret across containers.
|
# Auto-generated on first run if left empty. Only set if you need a stable secret across containers.
|
||||||
@@ -27,9 +31,32 @@ Server__PublicServer=false
|
|||||||
|
|
||||||
# ── Storage ──────────────────────────────────────────────────────────
|
# ── Storage ──────────────────────────────────────────────────────────
|
||||||
# Defaults are set in the Dockerfile to use /app/data for persistence.
|
# Defaults are set in the Dockerfile to use /app/data for persistence.
|
||||||
|
# Storage__Path=
|
||||||
# Storage__CleanupIntervalHours=1
|
# Storage__CleanupIntervalHours=1
|
||||||
# Storage__RetentionDays=30
|
# Storage__RetentionDays=30
|
||||||
|
|
||||||
|
# ── Uploads ──────────────────────────────────────────────────────────
|
||||||
|
# Uploads__MaxFileSizeMB=100
|
||||||
|
# Uploads__MaxImageSizeMB=10
|
||||||
|
# Uploads__MaxAudioSizeMB=10
|
||||||
|
# Uploads__MaxAvatarSizeMB=2
|
||||||
|
# Uploads__MaxAttachmentsPerMessage=10
|
||||||
|
|
||||||
|
# ── Spam protection ──────────────────────────────────────────────────
|
||||||
|
# Per-user flood/duplicate limits with auto-mute escalation; Mods and above are exempt.
|
||||||
|
# Spam__Enabled=true
|
||||||
|
# Spam__MaxMessagesPerWindow=8
|
||||||
|
# Spam__WindowSeconds=5
|
||||||
|
# Spam__MaxDuplicateMessages=3
|
||||||
|
# Spam__AutoMuteMinutes=5
|
||||||
|
# Spam__ViolationThreshold=5
|
||||||
|
# Spam__ViolationWindowMinutes=5
|
||||||
|
# Only first-time channel joins count — keep this above your public channel count.
|
||||||
|
# Spam__MaxJoinsPerWindow=25
|
||||||
|
# Spam__JoinWindowSeconds=30
|
||||||
|
# Spam__MaxChannelCreatesPerWindow=3
|
||||||
|
# Spam__ChannelCreateWindowMinutes=10
|
||||||
|
|
||||||
# ── IRC Gateway ──────────────────────────────────────────────────────
|
# ── IRC Gateway ──────────────────────────────────────────────────────
|
||||||
Irc__Enabled=false
|
Irc__Enabled=false
|
||||||
# Irc__Port=6667
|
# Irc__Port=6667
|
||||||
@@ -39,6 +66,12 @@ Irc__Enabled=false
|
|||||||
# Irc__TlsCertPassword=
|
# Irc__TlsCertPassword=
|
||||||
# Irc__ServerName=echohub
|
# Irc__ServerName=echohub
|
||||||
# Irc__Motd=Welcome to EchoHub IRC Gateway!
|
# Irc__Motd=Welcome to EchoHub IRC Gateway!
|
||||||
|
# Public base URL used to make attachment links absolute for IRC clients.
|
||||||
|
# Irc__PublicBaseUrl=https://chat.example.com
|
||||||
|
|
||||||
|
# ── CORS ─────────────────────────────────────────────────────────────
|
||||||
|
# Leave unset to allow all origins. Index per entry to whitelist.
|
||||||
|
# Cors__AllowedOrigins__0=https://chat.example.com
|
||||||
|
|
||||||
# ── Logging ──────────────────────────────────────────────────────────
|
# ── Logging ──────────────────────────────────────────────────────────
|
||||||
# Serilog__MinimumLevel__Default=Information
|
# Serilog__MinimumLevel__Default=Information
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install pinned linter
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
- name: Run markdownlint
|
- name: Run markdownlint
|
||||||
run: bash scripts/lint-markdown.sh
|
run: bash scripts/lint-markdown.sh
|
||||||
|
|
||||||
@@ -33,6 +42,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
|
||||||
|
|||||||
@@ -84,10 +84,12 @@ graph TD
|
|||||||
- **JWT auth** with short-lived access tokens and 30-day refresh tokens
|
- **JWT auth** with short-lived access tokens and 30-day refresh tokens
|
||||||
- **Channels** — create, set topics, delete (no 47-step permission wizard required)
|
- **Channels** — create, set topics, delete (no 47-step permission wizard required)
|
||||||
- **Moderation** — ban, mute (timed or permanent), kick, role assignment
|
- **Moderation** — ban, mute (timed or permanent), kick, role assignment
|
||||||
|
- **Registration control** — open, invite-code-gated (`/invite`), or closed; codes live in your own database
|
||||||
- **File & image uploads** with actual validation (magic bytes, not just trusting the extension)
|
- **File & image uploads** with actual validation (magic bytes, not just trusting the extension)
|
||||||
- **Image-to-ASCII** — because images in a terminal is objectively cool
|
- **Image-to-ASCII** — because images in a terminal is objectively cool
|
||||||
- **Presence tracking** — online/away/DND/invisible with custom status messages
|
- **Presence tracking** — online/away/DND/invisible with custom status messages
|
||||||
- **Rate limiting** — in case someone gets too excited
|
- **Rate limiting** — in case someone gets too excited
|
||||||
|
- **Spam protection** — per-user flood/duplicate limits with auto-mute escalation, covering TUI and IRC alike; configurable under `Spam`, Mods exempt
|
||||||
- **Auto-restart** on crash with exponential backoff — it picks itself back up
|
- **Auto-restart** on crash with exponential backoff — it picks itself back up
|
||||||
- **Serilog logging** — console + rolling file, because `Console.WriteLine` isn't a logging strategy
|
- **Serilog logging** — console + rolling file, because `Console.WriteLine` isn't a logging strategy
|
||||||
- **Zero config first run** — generates its own JWT secret and config on launch
|
- **Zero config first run** — generates its own JWT secret and config on launch
|
||||||
@@ -101,6 +103,8 @@ graph TD
|
|||||||
- **Clickable everything** — usernames, @mentions, #channels — just press Enter
|
- **Clickable everything** — usernames, @mentions, #channels — just press Enter
|
||||||
- **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
|
- **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)
|
- **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)
|
||||||
|
- **Replies** — quote a message, jump back to the original; being replied to pings like a mention
|
||||||
|
- **Your data is yours** — `/export` everything the server holds about you; `/deleteaccount` removes it
|
||||||
- **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
|
||||||
@@ -198,6 +202,8 @@ Auth works via `PASS`/`NICK`/`USER` or SASL PLAIN. New usernames are auto-regist
|
|||||||
| Feature | How it maps to IRC |
|
| Feature | How it maps to IRC |
|
||||||
| ------- | ------------------ |
|
| ------- | ------------------ |
|
||||||
| Text messages | Standard `PRIVMSG` (long messages split at ~400 byte chunks) |
|
| Text messages | Standard `PRIVMSG` (long messages split at ~400 byte chunks) |
|
||||||
|
| `/me` actions | Native CTCP ACTION in both directions |
|
||||||
|
| Replies | TUI replies arrive as `> nick: snippet \| text` |
|
||||||
| Images | `[Image: filename]` + download URL + ASCII art line-by-line |
|
| Images | `[Image: filename]` + download URL + ASCII art line-by-line |
|
||||||
| File uploads | `[File: filename] /api/files/{id}` |
|
| File uploads | `[File: filename] /api/files/{id}` |
|
||||||
| Channels | `JOIN`, `PART`, `NAMES`, `TOPIC`, `LIST` |
|
| Channels | `JOIN`, `PART`, `NAMES`, `TOPIC`, `LIST` |
|
||||||
@@ -226,6 +232,8 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------- | ----------- |
|
| ------- | ----------- |
|
||||||
| `/join <channel> [password]` | Join a channel (passphrase for encrypted channels) |
|
| `/join <channel> [password]` | Join a channel (passphrase for encrypted channels) |
|
||||||
|
| `/me <action>` | Action message — `* nick waves` (native CTCP ACTION over IRC) |
|
||||||
|
| `/banner <text>` | Render short text as a big ASCII banner |
|
||||||
| `/passwd <old> <new>` | Change the current encrypted channel's passphrase (creator only) |
|
| `/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) |
|
| `/size [s\|m\|l]` | ASCII-art size for attached images (no arg = picker) |
|
||||||
| `/downloadpath [path]` | Set the download folder (no path = native folder picker) |
|
| `/downloadpath [path]` | Set the download folder (no path = native folder picker) |
|
||||||
@@ -233,17 +241,20 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
|||||||
| `/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 |
|
||||||
| `/status <online\|away\|dnd\|invisible>` | Set your status |
|
| `/status <online\|away\|dnd\|invisible>` | Set your status |
|
||||||
| `/status <message>` | Set a status message |
|
| `/status msg <text>` | Set a status message (keeps your status; empty text clears it) |
|
||||||
| `/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 |
|
||||||
| `/profile` | Open profile editor |
|
| `/profile` | Open profile editor |
|
||||||
| `/users` | List online users in channel |
|
| `/users` | List online users in channel |
|
||||||
| `/servers` | Manage saved servers |
|
| `/servers` | Manage saved servers |
|
||||||
|
| `/invite [uses] [hours]` | Create a registration invite code (Admin+); also `list` / `revoke <code>` |
|
||||||
|
| `/export` | Download everything the server stores about you as JSON |
|
||||||
|
| `/deleteaccount` | Permanently delete your account (password re-confirmed) |
|
||||||
| `/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.
|
**Message actions:** **right-click a message** for a context menu — reply (quotes the message; Esc cancels a pending reply), 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
|
||||||
|
|
||||||
@@ -277,6 +288,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
|||||||
| `Jwt:Secret` | *(auto-generated)* | JWT signing key |
|
| `Jwt:Secret` | *(auto-generated)* | JWT signing key |
|
||||||
| `Server:Name` | `My EchoHub Server` | Server display name |
|
| `Server:Name` | `My EchoHub Server` | Server display name |
|
||||||
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
|
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
|
||||||
|
| `Server:Registration` | `open` | `open`, `invite` (codes via `/invite`, Admin+), or `closed` |
|
||||||
| `Server:PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) |
|
| `Server:PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) |
|
||||||
| `Server:PublicHost` | *(empty)* | Public hostname for directory listing |
|
| `Server:PublicHost` | *(empty)* | Public hostname for directory listing |
|
||||||
| `Irc:Enabled` | `false` | Enable the IRC gateway |
|
| `Irc:Enabled` | `false` | Enable the IRC gateway |
|
||||||
|
|||||||
@@ -101,6 +101,23 @@ Access tokens expire after 15 minutes, refresh tokens after 30 days with rotatio
|
|||||||
| `Server:PublicHost` | *(empty)* | Public hostname for the directory listing (e.g. `chat.example.com:5000`) |
|
| `Server:PublicHost` | *(empty)* | Public hostname for the directory listing (e.g. `chat.example.com:5000`) |
|
||||||
| `Server:Admins` | `[]` | Array of admin usernames (e.g. `["alice", "bob"]`) |
|
| `Server:Admins` | `[]` | Array of admin usernames (e.g. `["alice", "bob"]`) |
|
||||||
|
|
||||||
|
### Uploads
|
||||||
|
|
||||||
|
Per-attachment size limits by kind (in megabytes) and the per-message attachment cap. An
|
||||||
|
absent or partial `Uploads` section keeps the built-in defaults. See
|
||||||
|
[Messages & Attachments](messages-and-attachments.md) for how kinds are detected.
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Uploads:MaxImageSizeMB` | `10` | Max size for one image attachment |
|
||||||
|
| `Uploads:MaxAudioSizeMB` | `10` | Max size for one audio attachment |
|
||||||
|
| `Uploads:MaxFileSizeMB` | `100` | Max size for any other attachment |
|
||||||
|
| `Uploads:MaxAvatarSizeMB` | `2` | Max avatar upload size |
|
||||||
|
| `Uploads:MaxAttachmentsPerMessage` | `10` | Attachments allowed on a single message |
|
||||||
|
|
||||||
|
The server sizes its request-body limits from these values, so raising a limit here is all
|
||||||
|
that's needed — no separate Kestrel tuning.
|
||||||
|
|
||||||
### Encryption
|
### Encryption
|
||||||
|
|
||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/inst
|
|||||||
To install a specific version or to a custom directory:
|
To install a specific version or to a custom directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSfL .../install.sh | sh -s -- --version 0.2.11
|
curl -sSfL .../install.sh | sh -s -- --version 0.2.14
|
||||||
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
|
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -76,11 +76,11 @@ Then connect with any standard IRC client:
|
|||||||
irssi -c localhost -p 6667 -w <password> -n <username>
|
irssi -c localhost -p 6667 -w <password> -n <username>
|
||||||
```
|
```
|
||||||
|
|
||||||
IRC users must have an existing EchoHub account. Authentication works via `PASS`/`NICK`/`USER` or SASL PLAIN. Messages flow bidirectionally between IRC and TUI clients.
|
Your nick is your EchoHub username and the server password is your account password (`PASS`/`NICK`/`USER` or SASL PLAIN). Connecting with a new username registers the account. Messages flow bidirectionally between IRC and TUI clients.
|
||||||
|
|
||||||
For TLS, set `TlsEnabled: true`, `TlsPort: 6697`, and provide a PKCS#12 certificate path.
|
For TLS, set `TlsEnabled: true`, `TlsPort: 6697`, and provide a PKCS#12 certificate path.
|
||||||
|
|
||||||
See the [Architecture](architecture.md) page for details on how the IRC gateway integrates with the chat service.
|
See the [IRC Gateway guide](irc-gateway.md) for command mapping, attachment rendering, and limitations, or [Architecture](architecture.md) for how the gateway integrates with the chat service.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# IRC Gateway
|
||||||
|
|
||||||
|
Every EchoHub server can expose a second door: a built-in **IRC gateway** that speaks the
|
||||||
|
classic IRC protocol on port 6667. Any standard IRC client — irssi, WeeChat, HexChat,
|
||||||
|
Halloy — can join the same channels as TUI users, see the same messages, and chat with the
|
||||||
|
same accounts. Under the hood both protocols call the same chat service, so a message sent
|
||||||
|
from IRC appears instantly in the TUI and vice versa (see [Architecture](architecture.md)).
|
||||||
|
|
||||||
|
## Enabling the gateway
|
||||||
|
|
||||||
|
The gateway is off by default. Enable it in `appsettings.json` (or `Irc__Enabled=true` as an
|
||||||
|
environment variable):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Irc": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Port": 6667,
|
||||||
|
"TlsEnabled": false,
|
||||||
|
"TlsPort": 6697,
|
||||||
|
"TlsCertPath": "",
|
||||||
|
"ServerName": "echohub",
|
||||||
|
"Motd": "Welcome to EchoHub IRC Gateway!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The plaintext listener always starts on `Port`. The TLS listener on `TlsPort` starts only
|
||||||
|
when `TlsEnabled` is `true` **and** `TlsCertPath` points to a PKCS#12 (`.pfx`) certificate.
|
||||||
|
See the [configuration reference](configuration.md#irc-gateway) for every option.
|
||||||
|
|
||||||
|
## Connecting & authentication
|
||||||
|
|
||||||
|
Your IRC **nick is your EchoHub username** and your server password is your **account
|
||||||
|
password**. Two flows are supported:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# classic PASS/NICK/USER — most clients call this the "server password"
|
||||||
|
irssi -c chat.example.com -p 6667 -w <password> -n <username>
|
||||||
|
```
|
||||||
|
|
||||||
|
or **SASL PLAIN** (advertised via `CAP LS`), where the SASL username/password are the account
|
||||||
|
credentials.
|
||||||
|
|
||||||
|
A few things worth knowing:
|
||||||
|
|
||||||
|
- **Connecting auto-registers.** If the username doesn't exist yet, the gateway creates the
|
||||||
|
account with that password (usernames: 3–50 chars of `a-z 0-9 _ -`; passwords: 6+ chars).
|
||||||
|
The very first account ever created on a server becomes the **Owner**.
|
||||||
|
- Because of that, a typo'd password for an *existing* account fails with
|
||||||
|
`Username is already taken` — the gateway tried to log in, couldn't, then tried to register
|
||||||
|
the name. If you see that error, re-check your password.
|
||||||
|
- Connecting without a password is rejected: `Password required. Use PASS command or SASL PLAIN.`
|
||||||
|
|
||||||
|
## What maps to what
|
||||||
|
|
||||||
|
| IRC | EchoHub |
|
||||||
|
| --- | --- |
|
||||||
|
| `JOIN #room` | Join a channel (history is replayed on join) |
|
||||||
|
| `JOIN #room <key>` | Join a password-protected (`+k`) channel |
|
||||||
|
| `PART` / `QUIT` | Leave channel / disconnect |
|
||||||
|
| `LIST` | Public channels only (password-protected ones show a `[+k]` hint) |
|
||||||
|
| `TOPIC` | Read or set the channel topic (permission-checked) |
|
||||||
|
| `NAMES` / `WHO` | Online users in the channel |
|
||||||
|
| `WHOIS` | Profile: display name, channels, idle time, away status |
|
||||||
|
| `AWAY [message]` | Sets your EchoHub status to Away / back to Online |
|
||||||
|
| `MODE #room +k <key>` / `-k` | Set / clear the channel password |
|
||||||
|
|
||||||
|
Private (unlisted) channels don't appear in `LIST`, but members who know the exact name can
|
||||||
|
still `JOIN` them. Channels are not auto-created from IRC — create them from the TUI first.
|
||||||
|
|
||||||
|
## How messages look
|
||||||
|
|
||||||
|
- **Attachments** arrive as labeled link lines — `[Image: photo.png] https://…`,
|
||||||
|
`♪ [Audio: song.mp3] https://…`, `[File: report.pdf] https://…` — and image attachments
|
||||||
|
additionally render their **ASCII-art preview** using truecolor ANSI escapes, so a modern
|
||||||
|
terminal IRC client shows actual picture previews.
|
||||||
|
- **Link embeds** are appended as `│`-prefixed text lines.
|
||||||
|
- Long messages are split at word boundaries into IRC-safe lines (~400 bytes each);
|
||||||
|
incoming messages may be up to 2,000 characters like any EchoHub message.
|
||||||
|
- Your own messages aren't echoed back (standard IRC convention).
|
||||||
|
- Moderation actions surface natively: kicks arrive as `KICK`, bans and channel nukes as
|
||||||
|
server `NOTICE`s.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
The gateway bridges what IRC can express — and deliberately refuses what it can't:
|
||||||
|
|
||||||
|
- **No end-to-end encrypted rooms.** Joining an [encrypted room](encrypted-rooms.md) fails
|
||||||
|
with *"Cannot join channel — end-to-end encrypted, use the EchoHub client."* Bridging one
|
||||||
|
would require the server to hold the room key, breaking the zero-knowledge design.
|
||||||
|
- **No private messages.** `PRIVMSG` to a nick is rejected; EchoHub is channel-based.
|
||||||
|
- **Usernames, not display names.** Messages are attributed to the account username;
|
||||||
|
a user's display name is visible via `WHOIS`/`WHO` (realname field).
|
||||||
|
- **No client features.** Uploading attachments, profiles, themes, and reactions to status
|
||||||
|
changes are TUI-client features. Other users' status changes aren't pushed to IRC —
|
||||||
|
discover them with `WHOIS`/`WHO`.
|
||||||
|
|
||||||
|
## How IRC users appear to TUI users
|
||||||
|
|
||||||
|
Users connected *only* through the gateway are tagged `[irc]` in the users panel — a hint
|
||||||
|
that they can't receive encrypted content or use client-side features. Someone connected
|
||||||
|
with both an IRC client and the TUI shows untagged.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Messages & Attachments
|
||||||
|
|
||||||
|
An EchoHub message is **text content plus up to 10 attachments**, Discord-style. A plain chat
|
||||||
|
line is just a message with no attachments; a photo dump is one message with several files and
|
||||||
|
an optional caption. This page explains how to attach files, what happens to them on the way to
|
||||||
|
the server, and how other clients receive them.
|
||||||
|
|
||||||
|
## Message basics
|
||||||
|
|
||||||
|
| Limit | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Max message length | 2,000 characters |
|
||||||
|
| Max newlines per message | 30 (no blank-line runs) |
|
||||||
|
| Max attachments per message | 10 |
|
||||||
|
| Link embeds per message | first 3 URLs |
|
||||||
|
|
||||||
|
Multiline messages are written with `Ctrl+N` for a newline; `Enter` sends. URLs in a message
|
||||||
|
get link embeds (title, description, theme color) fetched by the server.
|
||||||
|
|
||||||
|
## Attaching files
|
||||||
|
|
||||||
|
All of these end up in the same place — the **staging tray** — and are sent together as one
|
||||||
|
message the next time you press `Enter`, with whatever you've typed as the caption:
|
||||||
|
|
||||||
|
- **Paste a copied file** — copy one *or several* files in your file manager and press
|
||||||
|
`Ctrl+V` in the input. All of them are staged at once.
|
||||||
|
- **Paste an image from the clipboard** — copy an image in a browser (right-click → *Copy
|
||||||
|
image*), take a screenshot (`Win+Shift+S`), or copy from an image editor, then `Ctrl+V`.
|
||||||
|
The image is attached directly as a PNG named `image.png` — no saving to disk first.
|
||||||
|
On Linux this uses `wl-paste` or `xclip`; on macOS it requires
|
||||||
|
[`pngpaste`](https://github.com/jcsalterego/pngpaste) (`brew install pngpaste`).
|
||||||
|
- **Drag & drop** — drop a file onto the terminal window; the client recognizes the dropped
|
||||||
|
path and stages the file.
|
||||||
|
- **`/send <filepath>`** — stage a file by path (quote paths containing spaces).
|
||||||
|
|
||||||
|
The input frame's title shows what's currently staged. `/clear` drops all staged attachments
|
||||||
|
without sending. Sending with an empty input is fine — the message is just the attachments.
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌ Message (2 attached: report.pdf, image.png) ──────────────┐
|
||||||
|
│ here's the summary and a screenshot_ │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**URL sends are different:** `/send <https://…>` sends an image URL immediately as its own
|
||||||
|
message — nothing is staged, and it isn't available in end-to-end encrypted rooms (the server
|
||||||
|
would have to fetch the image, which would defeat the encryption).
|
||||||
|
|
||||||
|
## Attachment kinds
|
||||||
|
|
||||||
|
The kind is detected per attachment, not per message:
|
||||||
|
|
||||||
|
| Kind | Detected by | Renders as | Default size limit |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Image** | Magic bytes: JPEG, PNG, GIF, WebP | ASCII-art preview in chat | 10 MB |
|
||||||
|
| **Audio** | Extension: `.mp3` `.wav` `.ogg` `.flac` `.aac` `.m4a` `.wma` | Playable row (▶) | 10 MB |
|
||||||
|
| **File** | Everything else | Downloadable row | 100 MB |
|
||||||
|
|
||||||
|
Limits are per file and server-configurable — see the `Uploads` section in the
|
||||||
|
[configuration guide](configuration.md) (`MaxImageSizeMB`, `MaxAudioSizeMB`, `MaxFileSizeMB`,
|
||||||
|
`MaxAttachmentsPerMessage`).
|
||||||
|
|
||||||
|
## Image previews (ASCII art)
|
||||||
|
|
||||||
|
Images are rendered in chat as half-block ASCII art. You pick the rendering size:
|
||||||
|
|
||||||
|
| Flag | Size | Feel |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `-s` / `/size s` | 40 × 40 | compact |
|
||||||
|
| `-m` / `/size m` | 80 × 80 | default |
|
||||||
|
| `-l` / `/size l` | 120 × 120 | detailed |
|
||||||
|
|
||||||
|
`/size` with no argument opens a picker; the choice persists as your default. A one-off
|
||||||
|
`-s|-m|-l` flag on `/send` applies to that message.
|
||||||
|
|
||||||
|
## Receiving attachments
|
||||||
|
|
||||||
|
Right-click a message (or press `F6` to select one with the arrow keys) for actions:
|
||||||
|
|
||||||
|
- **Images** → save to disk
|
||||||
|
- **Audio** → play (in-client playback)
|
||||||
|
- **Files** → download
|
||||||
|
|
||||||
|
Downloads go to your configured download folder — set it with `/downloadpath` (no argument
|
||||||
|
opens a native folder picker, or pass a path directly).
|
||||||
|
|
||||||
|
## Attachments in encrypted rooms
|
||||||
|
|
||||||
|
In an [end-to-end encrypted room](encrypted-rooms.md) every attachment is encrypted
|
||||||
|
client-side **before** upload:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
F[File bytes] -->|AES-256-GCM with room key| B[Ciphertext blob]
|
||||||
|
F -->|if image: render ASCII locally| A[ASCII preview]
|
||||||
|
A -->|room-encrypt| AP["$RC1$… preview"]
|
||||||
|
B --> S[Server stores blob + name + size]
|
||||||
|
AP --> S
|
||||||
|
```
|
||||||
|
|
||||||
|
The server never sees the file contents or the rendered preview — it stores an opaque blob and
|
||||||
|
broadcasts it to members, who decrypt locally. File **names and sizes remain visible** to the
|
||||||
|
server so the file list stays usable; don't put secrets in a file name. Pasted clipboard
|
||||||
|
images go through exactly the same pipeline.
|
||||||
|
|
||||||
|
## Deleting messages with attachments
|
||||||
|
|
||||||
|
Deleting a message also removes its uploaded attachment files from the server. You can always
|
||||||
|
delete your own messages; moderators can delete others' — see [Moderation & Roles](moderation.md).
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Moderation & Roles
|
||||||
|
|
||||||
|
Every EchoHub server has a four-tier role hierarchy. Moderation is **strictly hierarchical**:
|
||||||
|
acting on another user requires outranking them — equal rank is never enough — and a few
|
||||||
|
invariants protect the server owner from lockouts.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
| Role | Rank | Users panel glyph | How it's granted |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Owner** | 3 | ★ | The first account ever registered on the server |
|
||||||
|
| **Admin** | 2 | ♦ | Assigned by the Owner |
|
||||||
|
| **Mod** | 1 | ❀ | Assigned by an Admin or the Owner |
|
||||||
|
| **Member** | 0 | — | Everyone else |
|
||||||
|
|
||||||
|
Assign roles with `/role <user> <admin|mod|member>`. Two rules apply:
|
||||||
|
|
||||||
|
- You can only assign roles **strictly below your own** — an Admin can promote to Mod but
|
||||||
|
cannot create another Admin; only the Owner can.
|
||||||
|
- **Owner is not assignable and not demotable.** There is exactly one Owner (the first
|
||||||
|
account), nobody can be promoted to it, and the Owner's role can't be changed.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
| Command | Minimum role | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/kick <user> [reason]` | Mod | Disconnects the user. Not persistent — they can reconnect immediately. |
|
||||||
|
| `/ban <user> [reason]` | Admin | Persistent: flags the account banned and disconnects it. Banned accounts are rejected at login. |
|
||||||
|
| `/unban <user>` | Admin | Lifts a ban. |
|
||||||
|
| `/mute <user> [minutes]` | Mod | Blocks the user from sending messages or uploading files. Without a duration the mute is **indefinite**; with one it auto-expires (checked every ~15 seconds). |
|
||||||
|
| `/unmute <user>` | Mod | Lifts a mute early. |
|
||||||
|
| `/role <user> <role>` | Admin | Assign a role (see rules above). |
|
||||||
|
| `/nuke` | Mod | Deletes the **entire history of the current channel**, including all attachment files on disk. Channel-wide — no per-user check. |
|
||||||
|
|
||||||
|
Kick, ban, and mute all enforce the hierarchy: the target's role must be **strictly lower**
|
||||||
|
than yours. A Mod cannot kick another Mod; nobody can kick, ban, mute, or demote the Owner.
|
||||||
|
|
||||||
|
## Deleting messages
|
||||||
|
|
||||||
|
Deletion has its own, slightly different rule set:
|
||||||
|
|
||||||
|
- **Your own messages** — always deletable, whatever your role. Right-click a message →
|
||||||
|
*Delete message*, or press `F6`, pick the message, and hit `Delete`.
|
||||||
|
- **Someone else's messages** — requires **Mod or higher** *and* strictly outranking the
|
||||||
|
author. A Mod can delete a Member's message, but not another Mod's.
|
||||||
|
|
||||||
|
Deleting a message also purges its uploaded attachment blobs from the server's disk, and the
|
||||||
|
removal is broadcast live — the message disappears from everyone's chat immediately.
|
||||||
|
|
||||||
|
## How actions surface
|
||||||
|
|
||||||
|
Everyone in the channel sees moderation happen:
|
||||||
|
|
||||||
|
- **TUI clients** show system messages — *"alice was kicked (reason)"*, *"bob was banned"*,
|
||||||
|
*"Channel history has been cleared by a moderator."* The kicked or banned user themselves
|
||||||
|
gets a dialog with the reason, then the client disconnects.
|
||||||
|
- **IRC clients** get native protocol events: kicks arrive as a real `KICK` command, bans as
|
||||||
|
a server `NOTICE`. (See the [IRC Gateway guide](irc-gateway.md).)
|
||||||
|
|
||||||
|
Muted users aren't announced; they simply receive *"You are muted and cannot send messages."*
|
||||||
|
when they try to speak.
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- All checks run server-side in the moderation API — the client commands are conveniences,
|
||||||
|
and the same rules bind IRC users and any direct API caller.
|
||||||
|
- Bans are account-level, not IP-level. A banned person can register a fresh account; pair
|
||||||
|
bans with registration hygiene on public servers.
|
||||||
|
- In [end-to-end encrypted rooms](encrypted-rooms.md) moderation still works at the metadata
|
||||||
|
level — messages can be deleted and users muted/kicked by identity — but no moderator can
|
||||||
|
*read* the content, including the Owner.
|
||||||
@@ -2,6 +2,14 @@
|
|||||||
items:
|
items:
|
||||||
- name: Getting Started
|
- name: Getting Started
|
||||||
href: getting-started.md
|
href: getting-started.md
|
||||||
|
- name: TUI Guide
|
||||||
|
href: tui-guide.md
|
||||||
|
- name: Messages & Attachments
|
||||||
|
href: messages-and-attachments.md
|
||||||
|
- name: Moderation & Roles
|
||||||
|
href: moderation.md
|
||||||
|
- name: IRC Gateway
|
||||||
|
href: irc-gateway.md
|
||||||
- name: Docker
|
- name: Docker
|
||||||
href: docker.md
|
href: docker.md
|
||||||
- name: Architecture
|
- name: Architecture
|
||||||
@@ -10,6 +18,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
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# TUI Guide
|
||||||
|
|
||||||
|
Everything you can do in the EchoHub terminal client: keyboard shortcuts, mouse actions,
|
||||||
|
slash commands, themes, and the everyday behaviors (unread markers, auto-join, scrollback)
|
||||||
|
that make it feel like a proper IRC-era client with modern comforts.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌ Menu bar ──────────────────────────────────────────────────┐
|
||||||
|
│ ┌ Channels ─┐ ┌ Messages ────────────────────┐ ┌ Users ──┐ │
|
||||||
|
│ │ #general 3│ │ 12:01 <alice> hi │ │ ★ alice │ │
|
||||||
|
│ │ #dev │ │ ── new messages ── │ │ ❀ bob │ │
|
||||||
|
│ │ #random*~ │ │ 12:04 <bob> anyone around? │ │ carol │ │
|
||||||
|
│ └───────────┘ └──────────────────────────────┘ │ d [irc] │ │
|
||||||
|
│ ┌ Message │ Enter=send │ Tab=complete │ … ────┐ └─────────┘ │
|
||||||
|
│ │ _ │ │
|
||||||
|
│ └─────────────────────────────────────────────┘ │
|
||||||
|
│ Status: Connected │ v0.2.14 │ alice │ Act: #dev │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Channel list markers: `*` = password-protected, `~` = private (unlisted), plus unread counts
|
||||||
|
(orange when you were @mentioned). Users panel glyphs: `★` Owner, `♦` Admin, `❀` Mod,
|
||||||
|
`[irc]` for IRC-gateway-only users; status icons `●`/`○`/`◐`/`◌` for online/offline/away/dnd.
|
||||||
|
|
||||||
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
### In the message input
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `Enter` | Send the message (also sends staged attachments with the text as caption) |
|
||||||
|
| `Ctrl+N` | Insert a newline (multiline message) |
|
||||||
|
| `Tab` | Autocomplete a slash command (`/th` → `/theme`) |
|
||||||
|
| `Ctrl+V` (or `Ctrl+Y`) | Paste — copied files and images become attachments, text pastes normally ([details](messages-and-attachments.md)) |
|
||||||
|
| `Ctrl+C` / `Ctrl+X` | Copy / cut in the input |
|
||||||
|
| `Ctrl+W` | Delete the word left of the cursor |
|
||||||
|
| `Ctrl+K` | Open the search palette |
|
||||||
|
| `F6` | Move focus into the message list |
|
||||||
|
|
||||||
|
### In the message list (after `F6`)
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `↑` / `↓` | Select a message |
|
||||||
|
| `Enter` | Activate: play/download/save an attachment, open an `@mention`'s profile, join a `#channel`, or open the sender's profile |
|
||||||
|
| `Delete` / `Backspace` | Delete the selected message (with confirmation; [permission rules](moderation.md)) |
|
||||||
|
| `F6` | Return focus to the input |
|
||||||
|
|
||||||
|
### Anywhere
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `F2` | Toggle the users panel |
|
||||||
|
| `Ctrl+K` | Search palette |
|
||||||
|
| `Alt+Q` | Quit |
|
||||||
|
|
||||||
|
## The search palette (`Ctrl+K`)
|
||||||
|
|
||||||
|
A command-palette that searches **channels and app actions** — type to filter, `↓` to
|
||||||
|
navigate, `Enter` to jump. Actions include Connect, Disconnect, Logout, My Profile,
|
||||||
|
Set Status, Create/Delete Channel, Saved Servers, Toggle Users Panel, Check for Updates,
|
||||||
|
and Quit. `Ctrl+K` again closes it.
|
||||||
|
|
||||||
|
## Mouse
|
||||||
|
|
||||||
|
- **Right-click a message** for the context menu: save image / play audio / download file
|
||||||
|
(depending on the attachment), *Mention @user*, *View profile*, *Copy text*,
|
||||||
|
*Copy message ID*, *Delete message*.
|
||||||
|
- **Left-click a message** does the most useful thing for that line: attachments
|
||||||
|
play/download/save, `@mentions` and the sender open profiles, `#channel` references join
|
||||||
|
that channel.
|
||||||
|
- **Click a user** in the users panel to open their profile; **click a channel** to switch.
|
||||||
|
|
||||||
|
## Slash commands
|
||||||
|
|
||||||
|
Type `/help` in any channel for the full list. The highlights:
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `/status <online\|away\|dnd\|invisible>` or `/status <message>` | Presence / status message |
|
||||||
|
| `/nick <name>`, `/color <#hex>`, `/avatar <url or path>` | Display name, nick color, avatar |
|
||||||
|
| `/theme <name>` | Switch theme |
|
||||||
|
| `/send`, `/clear`, `/size`, `/downloadpath` | Attachments — see [Messages & Attachments](messages-and-attachments.md) |
|
||||||
|
| `/join <channel> [password]`, `/leave`, `/topic <text>` | Channel membership and topic |
|
||||||
|
| `/passwd <old> <new>` | Rotate an encrypted room's passphrase |
|
||||||
|
| `/profile [user]`, `/users`, `/meta` | Profiles, online users, room info |
|
||||||
|
| `/kick`, `/ban`, `/mute`, `/role`, `/nuke`, … | [Moderation](moderation.md) |
|
||||||
|
| `/servers`, `/quit` | Saved servers, exit |
|
||||||
|
|
||||||
|
Emoji shortcodes (`:smile:` style) are replaced live as you type.
|
||||||
|
|
||||||
|
## Themes
|
||||||
|
|
||||||
|
14 built-in themes: **Default, Transparent, TransparentLight, Classic, Light, Hacker,
|
||||||
|
Solarized, Dracula, Monokai, Nord, Gruvbox, Ocean, HighContrast, RosePine** — switch from
|
||||||
|
the User menu or `/theme <name>`. The two *Transparent* themes use no background color at
|
||||||
|
all, so your terminal's own background (and any blur/acrylic) shows through.
|
||||||
|
|
||||||
|
You can add your own: drop a theme JSON into `~/.echohub/themes/` and it appears in the list
|
||||||
|
(names that collide with a built-in are skipped).
|
||||||
|
|
||||||
|
## Everyday behaviors
|
||||||
|
|
||||||
|
- **Unread markers** — a `── new messages ──` rule marks where you left off in each channel,
|
||||||
|
irssi-style. Read positions are **persisted per server**, so the marker survives
|
||||||
|
reconnects and restarts. The status bar's `Act:` segment lists channels with activity
|
||||||
|
(orange when you were @mentioned), and day boundaries draw a date rule.
|
||||||
|
- **Auto-join** — connecting joins `#general` plus every channel you're a member of, so
|
||||||
|
unread counts and mentions accumulate everywhere. Channels you `/leave` stay left, and
|
||||||
|
password-protected or [encrypted rooms](encrypted-rooms.md) are never auto-prompted —
|
||||||
|
join those explicitly. `#general` is the home channel and can't be left or deleted.
|
||||||
|
- **Scrollback** — history loads 100 messages at a time; scrolling to the top of a channel
|
||||||
|
fetches the next page and keeps your position (no jump).
|
||||||
|
- **Drag & drop** — dropping a file onto the window stages it as an attachment.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>AurionDocs</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; display: grid; place-items: center; min-height: 100vh; margin: 0; background: #1a1a1e; color: #ddd; }
|
||||||
|
main { text-align: center; }
|
||||||
|
a { color: #7aa2f7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>AurionDocs</h1>
|
||||||
|
<p>Supplementary documentation for EchoHub.</p>
|
||||||
|
<p><a href="../index.html">← Back to EchoHub documentation</a></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,6 +4,9 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [v0.2.15](v0.2.15.md) - Invite Codes, Data Export & Deletion, /me, /banner, Replies, Open Images In Browser & IRC Image Links
|
||||||
|
- [v0.2.14](v0.2.14.md) - Clipboard Image & Multi-File Paste, E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish
|
||||||
|
- [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.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
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.15
|
||||||
|
href: v0.2.15.md
|
||||||
|
- name: v0.2.14
|
||||||
|
href: v0.2.14.md
|
||||||
|
- name: v0.2.13
|
||||||
|
href: v0.2.13.md
|
||||||
- name: v0.2.12
|
- name: v0.2.12
|
||||||
href: v0.2.12.md
|
href: v0.2.12.md
|
||||||
- name: v0.2.11
|
- name: v0.2.11
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ Private channels are now genuinely private: password-protected channels are end-
|
|||||||
- 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
|
- 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
|
- 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`)
|
- New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`)
|
||||||
- Timestamps in messages are now aware of the current culture and display the short time pattern for today's messages and the short date+time pattern for older messages.
|
- **`/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
|
## Bug Fixes
|
||||||
|
|
||||||
@@ -36,12 +39,16 @@ Private channels are now genuinely private: password-protected channels are end-
|
|||||||
- 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.
|
- 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 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 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]`
|
- IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]`
|
||||||
|
|
||||||
## API Changes
|
## 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)
|
- `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 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
|
- 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
|
- `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`
|
- **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`
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# v0.2.14
|
||||||
|
|
||||||
|
A reliability and security pass over end-to-end encrypted rooms: locked channels now offer the passphrase prompt instead of dead-ending on the "rejoin to unlock" placeholder, the client can no longer be tricked into sending plaintext into an encrypted room, and cached room keys are encrypted at rest instead of sitting in the config as base64. Ctrl+V grows up too — images copied from a browser or screenshot tool paste straight into the chat as attachments, and copying several files pastes them all into one message. Plus a set of IRC gateway fixes — decrypted image previews, display-name plumbing, and user/channel indicators.
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- **Paste images straight from the clipboard** — copy an image from a browser, a screenshot tool (Win+Shift+S), or an image editor and Ctrl+V it into the input: it's attached as a PNG (`image.png`), no saving to disk first, Discord-style. Transparency is preserved when the source provides PNG data; plain clipboard bitmaps are converted automatically. On Linux this uses `wl-paste`/`xclip`; on macOS it requires `pngpaste`. In end-to-end encrypted rooms pasted images go through the same client-side encryption as any other attachment.
|
||||||
|
- **Multi-file paste** — copying several files in your file manager and pasting attaches them all to a single message (up to the 10-attachment cap), staged as one batch alongside anything you type as the caption. Previously each pasted file was routed through its own `/send`, which could misbehave on large batches.
|
||||||
|
- **Room keys encrypted at rest** — the per-channel room keys cached so you don't retype a passphrase every launch are no longer stored as plain base64 in `config.json`. On Windows they're protected with DPAPI (current-user scope); on Linux/macOS with AES-GCM under a per-user key file created with `0600` permissions next to the config. Existing plain entries migrate to the encrypted format automatically on first load. The passphrase itself is never stored in any form.
|
||||||
|
- **`[irc]` tag in the users panel** — users online only through the IRC gateway are tagged `[irc]`, useful context since IRC clients lack encryption, attachments, and profiles. Someone also running the TUI shows untagged.
|
||||||
|
- **`~` marker for private channels** — the channel list now marks private (unlisted) channels with a trailing `~`, alongside the existing `*` for password-protected ones (`#room*~` when both apply).
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- **Locked encrypted channels now prompt for the passphrase.** Auto-joining your channels at connect silently entered end-to-end encrypted rooms you're a member of without running the unlock flow — on a new device (or after a cancelled prompt) the room showed only `[encrypted — rejoin this channel with its passphrase to unlock]` placeholders, and only a manual `/join` recovered it. Selecting the channel now offers the passphrase prompt; entering it unlocks history and live messages in place. Cancelling is remembered for the session so reselecting the channel doesn't nag — `/join` or trying to send always re-offers the prompt.
|
||||||
|
- **A stale cached room key no longer beats a fresh one.** If an encrypted channel was deleted and recreated under the same name, a client that still had the old key cached kept encrypting messages nobody else could read. Typing the passphrase on join now always adopts the key from the server's current envelope, replacing the stale cache.
|
||||||
|
- **IRC clients no longer get flooded with ciphertext for image messages.** The gateway forwarded image ASCII previews without stripping transport encryption, spamming IRC clients with one enormous `$ENC$v1$…` line per image. Previews are now decrypted before formatting, and any that still can't be read (e.g. end-to-end room ciphertext the server cannot decrypt) are skipped in favor of the plain `[Image: name] url` line.
|
||||||
|
- **Display names now show on chat messages.** Messages only carried the sender's username, so a configured display name appeared in the user list but not on the messages themselves. Live messages, history, and attachment messages now all carry it; mention and profile lookups stay keyed to the username.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **No plaintext can leak into an encrypted room.** Previously, a client without the room key silently sent unencrypted text into an end-to-end encrypted channel (and other members saw it as a normal message, none the wiser it went over the wire readable by the server). All send paths — typed messages, staged file attachments, and URL sends — are now blocked while a room is locked: the client offers the unlock prompt, keeps staged files in the tray, and refuses to transmit until the key is present, with a hard guard at the connection layer as backstop.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# v0.2.15
|
||||||
|
|
||||||
|
A big one. Server owners can gate registration behind invite codes, users can export or delete
|
||||||
|
everything the server holds about them, and chat gets three classics — `/me` actions that
|
||||||
|
interop natively with IRC, `/banner` ASCII text, and message replies with quote rendering and
|
||||||
|
jump-to-original. Images become properly shareable: every image gets an **[open]** action that
|
||||||
|
views it in your browser (or, in end-to-end encrypted rooms, decrypts and opens it locally).
|
||||||
|
The IRC gateway stops painting ANSI art nobody asked for and posts plain image links any IRC
|
||||||
|
client can open or auto-preview. And a long-standing dual-session annoyance is fixed: messages
|
||||||
|
you send from the TUI now reach your own connected IRC client instantly.
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- **Invite-gated registration** — set `Server:Registration` to `"invite"` (or `"closed"`) in
|
||||||
|
`appsettings.json` and new accounts require a valid invite code; the default `"open"` keeps
|
||||||
|
today's behavior. Admins mint codes with `/invite [uses] [hours]` (single-use, never-expiring
|
||||||
|
by default), list them with `/invite list`, and revoke with `/invite revoke <code>`. Codes are
|
||||||
|
unguessable (`K7QM-3XPF` style), stored only in your server's own database, and consumed
|
||||||
|
atomically so a code's last use can't be taken twice. The IRC gateway respects the gate too:
|
||||||
|
unknown nicknames connecting to an invite-only server get a clear error instead of a silent
|
||||||
|
auto-registered account. The very first account (server owner bootstrap) is always allowed.
|
||||||
|
- **Data export** — `/export` downloads everything the server stores about you (profile, your
|
||||||
|
messages, attachment metadata) as JSON into your download folder. Content from end-to-end
|
||||||
|
encrypted rooms appears as ciphertext, because the server never had the plaintext — "you own
|
||||||
|
the data", demonstrably.
|
||||||
|
- **Account deletion** — `/deleteaccount` permanently removes your account after a typed
|
||||||
|
confirmation and password re-check: profile, sessions, and every file you uploaded are
|
||||||
|
deleted. Your messages remain (deleting them would gut other people's conversations) but are
|
||||||
|
re-attributed to a reserved `deleted-user` name. The last remaining Owner can't self-delete.
|
||||||
|
- **`/me` actions** — `/me waves` renders as `* nick waves`. On the wire it's a real CTCP
|
||||||
|
ACTION, so irssi/WeeChat users see a native action line, and their `/me` renders properly in
|
||||||
|
the TUI. Works in encrypted rooms (the marker encrypts with the text).
|
||||||
|
- **`/banner <text>`** — renders short text (up to 20 characters) as a 5-row block-letter
|
||||||
|
banner. Rendered locally with a built-in font — no network, no dependencies — and sent as
|
||||||
|
plain message content, so it works over IRC and in encrypted rooms.
|
||||||
|
- **Message replies** — right-click a message (or F6-select) and pick **Reply**: the input
|
||||||
|
shows a "Replying to" strip (Esc cancels), and the sent message renders with a dim
|
||||||
|
`┌ nick: snippet` quote line. Activating the quote line jumps to the original message.
|
||||||
|
Being replied to lights up the channel like an @mention. IRC clients see the familiar
|
||||||
|
`> nick: snippet | text` convention. In encrypted rooms the quoted snippet is decrypted
|
||||||
|
client-side with the room key — the server still never reads room content.
|
||||||
|
- **`[open]` images without saving them** — every image attachment now shows `[open] [↓ save original]` beneath its preview. Open views the image in your default browser straight from the server; in end-to-end encrypted rooms (where a browser would only see ciphertext) the client downloads, decrypts with the room key, and opens the image in your OS viewer from a temp file instead. Both actions are individually clickable, Enter on the line opens, and the right-click menu carries both.
|
||||||
|
- **Attachment links work in a browser** — `GET /api/files/{id}` is now a capability URL: the unguessable GUID in the link is the access token (Discord-CDN style), so attachment links can be opened directly in a browser or shared to IRC without a login token. Images and audio are served inline so the browser displays them instead of forcing a download. Blobs from encrypted rooms remain ciphertext, so their links reveal nothing.
|
||||||
|
- **IRC gets image links instead of terminal art** — the gateway no longer floods IRC clients with truecolor-ANSI ASCII art for images. Each attachment is now a single line — `[Image: photo.png] https://your-server/api/files/…` — the convention every IRC client understands, and ones like TheLounge or IRCCloud auto-preview. Set the new `Irc:PublicBaseUrl` option (e.g. `"https://chat.example.com"`) so those links come out absolute; unset, they fall back to relative paths as before.
|
||||||
|
- **Spam protection** — per-user message flood and duplicate-message limits, join and
|
||||||
|
channel-creation throttles, and automatic escalation: enough rejected sends in a few minutes
|
||||||
|
earns a timed auto-mute through the normal mute system (moderators see it, and it expires on
|
||||||
|
its own). One guard covers every ingress — TUI and IRC clients hit the same limits. Mods and
|
||||||
|
above are exempt, everything is configurable under the new `Spam` section
|
||||||
|
(`Spam:Enabled` master switch, lenient defaults a fast typist won't trip), and the guard only
|
||||||
|
ever sees stored content — encrypted-room messages stay ciphertext.
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- **Strict `/status`** — `/status garbage` is now an error instead of silently becoming your
|
||||||
|
status message (and resetting you to Online). Status messages moved to `/status msg <text>`,
|
||||||
|
which preserves your current status; `/status msg` with no text clears it. The server also
|
||||||
|
rejects out-of-range status values sent by misbehaving clients.
|
||||||
|
- **Registration dialog** — gained Invite Code support, and the Display Name field you could
|
||||||
|
already type into is now actually sent with registration.
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- **Messages sent from the TUI now reach your own IRC session.** With the same account online via both the TUI and an IRC client, messages sent from the TUI never appeared in the IRC client until it reconnected (which replayed history). The gateway suppressed the sender's echo by *nickname*, which swallowed the message for every IRC connection on that account — it now excludes only the exact connection a message originated from, so all your other sessions (a second IRC client included) receive it immediately.
|
||||||
|
- **Channel deleted event** — when a channel is deleted, all connected clients are now immediately notified via the new `ChannelDeleted` SignalR event. The channel is removed from the channel list. Previously only the deleting client was able to delete the channel. Other clients would see the channel linger until the next manual refresh.
|
||||||
|
- **Deleted accounts don't break history** — channel history now left-joins users, so messages
|
||||||
|
from deleted accounts render (as `deleted-user`) instead of silently disappearing.
|
||||||
|
|
||||||
|
## Notes for server operators
|
||||||
|
|
||||||
|
- New config key: `Server:Registration` — `"open"` (default), `"invite"`, or `"closed"`.
|
||||||
|
- New config section: `Spam` — flood/duplicate/join/create thresholds and auto-mute duration;
|
||||||
|
see `appsettings.example.json`. On by default with lenient limits; `Spam:Enabled: false`
|
||||||
|
turns it all off. Note `Spam:MaxJoinsPerWindow` counts only *first-time* channel joins —
|
||||||
|
keep it above your public channel count so a new member's first connect isn't throttled.
|
||||||
|
- New REST endpoints: `POST/GET/DELETE /api/invites` (Admin+), `GET /api/users/me/export`,
|
||||||
|
`DELETE /api/users/me`.
|
||||||
|
- One new database migration (`AddInvitesAndReplies`) applies automatically on startup.
|
||||||
+2
-2
@@ -46,7 +46,7 @@
|
|||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"files": ["**/*.{md,yml}"],
|
"files": ["**/*.{md,yml}"],
|
||||||
"exclude": ["_site/**", "_api_meta/**"]
|
"exclude": ["_site/**", "_api_meta/**", "auriondocs/**"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "_api_meta/core",
|
"src": "_api_meta/core",
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
],
|
],
|
||||||
"resource": [
|
"resource": [
|
||||||
{
|
{
|
||||||
"files": ["images/**"]
|
"files": ["images/**", "auriondocs/**"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"output": "_site",
|
"output": "_site",
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-style chat
|
|||||||
## Quick Links
|
## Quick Links
|
||||||
|
|
||||||
- [Getting Started](articles/getting-started.md) - Set up and run EchoHub
|
- [Getting Started](articles/getting-started.md) - Set up and run EchoHub
|
||||||
|
- [TUI Guide](articles/tui-guide.md) - Keyboard shortcuts, slash commands, and everyday usage
|
||||||
|
- [Messages & Attachments](articles/messages-and-attachments.md) - Attaching, pasting, and receiving files
|
||||||
|
- [Encrypted Rooms](articles/encrypted-rooms.md) - End-to-end encrypted channels
|
||||||
|
- [IRC Gateway](articles/irc-gateway.md) - Connect with any IRC client
|
||||||
- [Architecture](articles/architecture.md) - System design and IRC gateway
|
- [Architecture](articles/architecture.md) - System design and IRC gateway
|
||||||
- [API Reference](api/index.md) - Generated C# API documentation
|
- [API Reference](api/index.md) - Generated C# API documentation
|
||||||
- [Changelog](changelog/index.md) - Release history
|
- [Changelog](changelog/index.md) - Release history
|
||||||
|
|||||||
@@ -7,3 +7,5 @@
|
|||||||
- name: API
|
- name: API
|
||||||
href: api/
|
href: api/
|
||||||
homepage: api/index.md
|
homepage: api/index.md
|
||||||
|
- name: AurionDocs
|
||||||
|
href: auriondocs/index.html
|
||||||
|
|||||||
Generated
+1365
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "echohub",
|
||||||
|
"private": true,
|
||||||
|
"description": "Repo tooling — markdown linting. Not an npm package.",
|
||||||
|
"scripts": {
|
||||||
|
"lint:md": "markdownlint-cli2",
|
||||||
|
"lint:md:fix": "markdownlint-cli2 --fix"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"markdownlint-cli2": "^0.23.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# EchoHub - Markdown Lint
|
||||||
|
#
|
||||||
|
# Runs markdownlint-cli2 over the repo. Rules, globs, and ignores all live in
|
||||||
|
# .markdownlint-cli2.jsonc; the linter version is pinned in package.json.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# .\scripts\lint-markdown.ps1 # lint-only, exits non-zero on violations
|
||||||
|
# .\scripts\lint-markdown.ps1 -Fix # auto-fix what markdownlint can
|
||||||
|
#
|
||||||
|
# Requires: Node + npx on PATH. Prefers the locally installed linter
|
||||||
|
# (`npm install` once); otherwise npx fetches the same pinned version.
|
||||||
|
|
||||||
|
param(
|
||||||
|
[switch]$Fix
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Host " ERROR: npx not found on PATH. Install Node.js (https://nodejs.org) and retry." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$RootDir = Split-Path -Parent $ScriptDir
|
||||||
|
|
||||||
|
Push-Location $RootDir
|
||||||
|
try {
|
||||||
|
# Local install is version-pinned via package.json/package-lock.json; the
|
||||||
|
# npx fallback pins the same version so CI and local can never drift.
|
||||||
|
if (Test-Path "node_modules/.bin/markdownlint-cli2") {
|
||||||
|
$npxArgs = @('--no-install', 'markdownlint-cli2')
|
||||||
|
} else {
|
||||||
|
$npxArgs = @('--yes', '[email protected]')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Fix) {
|
||||||
|
Write-Host " Auto-fix mode (--fix): markdownlint will rewrite files in place." -ForegroundColor Yellow
|
||||||
|
$npxArgs += '--fix'
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " > npx $($npxArgs -join ' ')" -ForegroundColor Gray
|
||||||
|
& npx @npxArgs
|
||||||
|
$exit = $LASTEXITCODE
|
||||||
|
|
||||||
|
if ($exit -eq 0) {
|
||||||
|
Write-Host " Markdown lint clean." -ForegroundColor Green
|
||||||
|
} elseif ($Fix) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Some issues could not be auto-fixed. Review the output above and fix manually." -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Markdown lint failed. Re-run with -Fix to auto-correct the fixable rules:" -ForegroundColor Red
|
||||||
|
Write-Host " .\scripts\lint-markdown.ps1 -Fix" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
exit $exit
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
#
|
#
|
||||||
# Lint all Markdown files in the repository.
|
# Lint all Markdown files in the repository.
|
||||||
# Config, globs, and ignores are defined in .markdownlint-cli2.jsonc.
|
# Config, globs, and ignores are defined in .markdownlint-cli2.jsonc.
|
||||||
|
# The linter version is pinned in package.json — run `npm install` once,
|
||||||
|
# or just use `npm run lint:md` / `npm run lint:md:fix` directly.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/lint-markdown.sh # check
|
# ./scripts/lint-markdown.sh # check
|
||||||
@@ -12,11 +14,12 @@ set -euo pipefail
|
|||||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
# Resolve markdownlint-cli2 binary
|
# Prefer the locally installed (version-pinned) linter; fall back to a one-off
|
||||||
if command -v markdownlint-cli2 &>/dev/null; then
|
# npx install of the same version pinned in package.json.
|
||||||
LINT_CMD="markdownlint-cli2"
|
if [ -x "node_modules/.bin/markdownlint-cli2" ]; then
|
||||||
|
LINT_CMD="npx --no-install markdownlint-cli2"
|
||||||
else
|
else
|
||||||
LINT_CMD="npx --yes markdownlint-cli2"
|
LINT_CMD="npx --yes markdownlint-cli2@0.23.0"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Linting Markdown files..."
|
echo "Linting Markdown files..."
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
**/obj/
|
||||||
|
**/bin/
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.12</Version>
|
<Version>0.2.15</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -35,11 +35,28 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly List<string> _stagedAttachments = [];
|
private readonly List<string> _stagedAttachments = [];
|
||||||
|
|
||||||
|
// Temp PNGs created for clipboard-image pastes; deleted once their message is sent
|
||||||
|
// (or the staging tray is cleared) so pasted screenshots don't pile up in %TEMP%.
|
||||||
|
private readonly HashSet<string> _tempPastedFiles = [];
|
||||||
|
|
||||||
|
// E2E channels whose unlock prompt the user cancelled — don't nag on every reselect.
|
||||||
|
// Cleared on connect/reconnect; an explicit /join or a send attempt re-offers the prompt.
|
||||||
|
private readonly HashSet<string> _declinedUnlocks = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Pending reply target — the next text message in that channel is sent as a reply to it.
|
||||||
|
private (string Channel, Guid MessageId)? _pendingReply;
|
||||||
|
|
||||||
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;
|
||||||
@@ -61,6 +78,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();
|
||||||
}
|
}
|
||||||
@@ -82,6 +101,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnDisconnectRequested += HandleDisconnect;
|
_mainWindow.OnDisconnectRequested += HandleDisconnect;
|
||||||
_mainWindow.OnLogoutRequested += HandleLogout;
|
_mainWindow.OnLogoutRequested += HandleLogout;
|
||||||
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
|
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
|
||||||
|
_mainWindow.OnFilesStaged += HandleFilesStaged;
|
||||||
|
_mainWindow.OnImagePasted += HandleImagePasted;
|
||||||
_mainWindow.OnChannelSelected += HandleChannelSelected;
|
_mainWindow.OnChannelSelected += HandleChannelSelected;
|
||||||
_mainWindow.OnProfileRequested += HandleProfileRequested;
|
_mainWindow.OnProfileRequested += HandleProfileRequested;
|
||||||
_mainWindow.OnStatusRequested += HandleStatusRequested;
|
_mainWindow.OnStatusRequested += HandleStatusRequested;
|
||||||
@@ -92,6 +113,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
||||||
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||||
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
|
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
|
||||||
|
_mainWindow.OnImageOpenRequested += HandleImageOpenRequested;
|
||||||
_mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested;
|
_mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested;
|
||||||
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
||||||
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
||||||
@@ -99,6 +121,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
||||||
_mainWindow.OnSearchRequested += HandleSearchRequested;
|
_mainWindow.OnSearchRequested += HandleSearchRequested;
|
||||||
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
|
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
|
||||||
|
_mainWindow.OnReplyRequested += HandleReplyRequested;
|
||||||
|
_mainWindow.OnReplyCancelRequested += HandleReplyCancelRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||||
@@ -121,6 +145,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_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;
|
||||||
@@ -130,17 +155,200 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_commandHandler.OnNukeChannel += HandleCmdNukeChannel;
|
_commandHandler.OnNukeChannel += HandleCmdNukeChannel;
|
||||||
_commandHandler.OnTestSound += HandleCmdTestSound;
|
_commandHandler.OnTestSound += HandleCmdTestSound;
|
||||||
_commandHandler.OnQuit += HandleCmdQuit;
|
_commandHandler.OnQuit += HandleCmdQuit;
|
||||||
|
_commandHandler.OnSendAction += HandleCmdSendAction;
|
||||||
|
_commandHandler.OnSendBanner += HandleCmdSendBanner;
|
||||||
|
_commandHandler.OnCreateInvite += HandleCmdCreateInvite;
|
||||||
|
_commandHandler.OnListInvites += HandleCmdListInvites;
|
||||||
|
_commandHandler.OnRevokeInvite += HandleCmdRevokeInvite;
|
||||||
|
_commandHandler.OnExportData += HandleCmdExportData;
|
||||||
|
_commandHandler.OnDeleteAccount += HandleCmdDeleteAccount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command Handlers ──────────────────────────────────────────────────
|
// ── Command Handlers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task HandleCmdSetStatus(UserStatus status, string? message)
|
private async Task HandleCmdSetStatus(UserStatus? status, string? message)
|
||||||
{
|
{
|
||||||
if (!_conn.IsConnected) return;
|
if (!_conn.IsConnected) return;
|
||||||
|
|
||||||
await _conn.UpdateStatusAsync(status, message);
|
// null status keeps the current one; null message keeps it, empty clears it —
|
||||||
_session.Status = status;
|
// so "/status away" no longer wipes your message and "/status msg brb" keeps Away
|
||||||
_session.StatusMessage = message;
|
var newStatus = status ?? _session.Status;
|
||||||
|
var newMessage = message is null
|
||||||
|
? _session.StatusMessage
|
||||||
|
: (message.Length == 0 ? null : message);
|
||||||
|
|
||||||
|
await _conn.UpdateStatusAsync(newStatus, newMessage);
|
||||||
|
_session.Status = newStatus;
|
||||||
|
_session.StatusMessage = newMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleCmdSendAction(string text)
|
||||||
|
{
|
||||||
|
if (!_conn.IsConnected) return Task.CompletedTask;
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||||
|
|
||||||
|
// CTCP ACTION content flows through the normal send path — room encryption included
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
if (!await EnsureRoomUnlockedForSendAsync(channel))
|
||||||
|
return;
|
||||||
|
await _conn.SendMessageAsync(channel, MessageConventions.FormatAction(text));
|
||||||
|
}, "Send failed");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleCmdSendBanner(string text)
|
||||||
|
{
|
||||||
|
if (!_conn.IsConnected) return Task.CompletedTask;
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||||
|
|
||||||
|
var banner = AsciiBannerService.Render(text);
|
||||||
|
if (banner is null)
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError(
|
||||||
|
$"Nothing to render — /banner supports letters, digits, and basic punctuation (max {AsciiBannerService.MaxInputLength} chars)."));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
if (!await EnsureRoomUnlockedForSendAsync(channel))
|
||||||
|
return;
|
||||||
|
await _conn.SendMessageAsync(channel, banner);
|
||||||
|
}, "Send failed");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleCmdCreateInvite(int? maxUses, int? expiresHours)
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated) return Task.CompletedTask;
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
var invite = await _conn.Api!.CreateInviteAsync(maxUses, expiresHours);
|
||||||
|
if (invite is null) return;
|
||||||
|
var expiry = invite.ExpiresAt is { } exp ? $", expires {exp.ToLocalTime():yyyy-MM-dd HH:mm}" : "";
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(channel,
|
||||||
|
$"Invite code: {invite.Code} (uses: {invite.MaxUses}{expiry})\n" +
|
||||||
|
$"Share it out-of-band. Revoke with: /invite revoke {invite.Code}"));
|
||||||
|
}, "Failed to create invite");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleCmdListInvites()
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated) return Task.CompletedTask;
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
var invites = await _conn.Api!.GetInvitesAsync();
|
||||||
|
var text = invites.Count == 0
|
||||||
|
? "No invite codes. Create one with /invite [uses] [hours]."
|
||||||
|
: string.Join('\n', invites.Select(i =>
|
||||||
|
{
|
||||||
|
var state = i.UseCount >= i.MaxUses ? "used up"
|
||||||
|
: i.ExpiresAt is { } exp && exp <= DateTimeOffset.UtcNow ? "expired"
|
||||||
|
: i.ExpiresAt is { } e2 ? $"expires {e2.ToLocalTime():yyyy-MM-dd HH:mm}"
|
||||||
|
: "active";
|
||||||
|
return $"{i.Code} {i.UseCount}/{i.MaxUses} used ({state}, by {i.CreatedByUsername})";
|
||||||
|
}));
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, text));
|
||||||
|
}, "Failed to list invites");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleCmdRevokeInvite(string code)
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated) return Task.CompletedTask;
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
await _conn.Api!.RevokeInviteAsync(code);
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Invite {code.ToUpperInvariant()} revoked."));
|
||||||
|
}, "Failed to revoke invite");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleCmdExportData()
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated) return Task.CompletedTask;
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
var json = await _conn.Api!.ExportMyDataAsync();
|
||||||
|
var fileName = $"echohub-export-{_session.Username}-{DateTime.Now:yyyyMMdd-HHmmss}.json";
|
||||||
|
var destination = DedupPath(GetDownloadDir(), fileName);
|
||||||
|
await File.WriteAllTextAsync(destination, json);
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(channel))
|
||||||
|
_messageManager.AddSystemMessage(channel,
|
||||||
|
$"Data export saved to: {destination}\n(Encrypted-room content in it is ciphertext — the server never had the plaintext.)");
|
||||||
|
});
|
||||||
|
}, "Export failed");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleCmdDeleteAccount()
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated) return Task.CompletedTask;
|
||||||
|
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
var confirm = MessageBox.ErrorQuery(_app, "Delete Account",
|
||||||
|
$"This permanently deletes '{_session.Username}' on this server:\n" +
|
||||||
|
"profile, sessions, and every file you uploaded.\n" +
|
||||||
|
"Your messages remain, attributed to 'deleted-user'.\n\n" +
|
||||||
|
"This cannot be undone.",
|
||||||
|
"Cancel", "Delete my account");
|
||||||
|
if (confirm != 1) return;
|
||||||
|
|
||||||
|
var password = PromptPassword("Enter your password to confirm deletion:");
|
||||||
|
if (password is null) return;
|
||||||
|
|
||||||
|
PersistLastReads();
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
var baseUrl = _conn.Api!.BaseUrl;
|
||||||
|
await _conn.Api!.DeleteMyAccountAsync(password);
|
||||||
|
ClearSavedToken(baseUrl);
|
||||||
|
await _conn.CleanupAsync();
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.ClearAll();
|
||||||
|
_mainWindow.UpdateStatusBar("Disconnected");
|
||||||
|
MessageBox.Query(_app, "Account Deleted",
|
||||||
|
"Your account and uploaded files have been deleted from this server.", "OK");
|
||||||
|
});
|
||||||
|
}, "Account deletion failed", "DeleteAccount");
|
||||||
|
});
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Modal password prompt; returns null when cancelled or empty.</summary>
|
||||||
|
private string? PromptPassword(string prompt)
|
||||||
|
{
|
||||||
|
string? result = null;
|
||||||
|
var dialog = new Dialog { Title = "Confirm Password", Width = 56, Height = 8 };
|
||||||
|
var label = new Label { Text = prompt, X = 1, Y = 1 };
|
||||||
|
var field = new TextField { X = 1, Y = 2, Width = Terminal.Gui.ViewBase.Dim.Fill(2), Secret = true };
|
||||||
|
var ok = new Button { Text = "Confirm", IsDefault = true, X = Terminal.Gui.ViewBase.Pos.Center() - 12, Y = 4 };
|
||||||
|
var cancel = new Button { Text = "Cancel", X = Terminal.Gui.ViewBase.Pos.Center() + 2, Y = 4 };
|
||||||
|
ok.Accepting += (_, e) => { result = field.Text; e.Handled = true; _app.RequestStop(); };
|
||||||
|
cancel.Accepting += (_, e) => { result = null; e.Handled = true; _app.RequestStop(); };
|
||||||
|
dialog.Add(label, field, ok, cancel);
|
||||||
|
field.SetFocus();
|
||||||
|
_app.Run(dialog);
|
||||||
|
return string.IsNullOrEmpty(result) ? null : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleCmdSetNick(string displayName)
|
private async Task HandleCmdSetNick(string displayName)
|
||||||
@@ -178,7 +386,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
{
|
{
|
||||||
if (_conn.RoomKeys.HasKey(channel))
|
// Also blocks locked E2E channels (no cached key) — a URL send would be plaintext
|
||||||
|
if (_conn.RoomKeys.HasKey(channel) || _conn.RoomKeys.IsChannelEncrypted(channel))
|
||||||
{
|
{
|
||||||
InvokeUI(() => _mainWindow.ShowError(
|
InvokeUI(() => _mainWindow.ShowError(
|
||||||
"Sending by URL isn't available in encrypted channels — download the file and /send it instead."));
|
"Sending by URL isn't available in encrypted channels — download the file and /send it instead."));
|
||||||
@@ -207,11 +416,89 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
private Task HandleCmdClearAttachments()
|
private Task HandleCmdClearAttachments()
|
||||||
{
|
{
|
||||||
|
var temps = _stagedAttachments.Where(_tempPastedFiles.Contains).ToList();
|
||||||
|
_tempPastedFiles.ExceptWith(temps);
|
||||||
|
CleanupPastedTempFiles(temps);
|
||||||
|
|
||||||
_stagedAttachments.Clear();
|
_stagedAttachments.Clear();
|
||||||
InvokeUI(RefreshStagingTray);
|
InvokeUI(RefreshStagingTray);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stages a batch of local files (multi-file paste or drag-and-drop) as attachments of the
|
||||||
|
/// next message. Runs synchronously on the UI thread — unlike routing each file through a
|
||||||
|
/// fire-and-forget /send command, a 10-file paste can't race the staging list.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleFilesStaged(string channel, IReadOnlyList<string> files)
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated || !_conn.IsConnected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var slotsLeft = HubConstants.MaxAttachmentsPerMessage - _stagedAttachments.Count;
|
||||||
|
_stagedAttachments.AddRange(files.Take(Math.Max(0, slotsLeft)));
|
||||||
|
|
||||||
|
if (files.Count > slotsLeft)
|
||||||
|
_mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message.");
|
||||||
|
|
||||||
|
RefreshStagingTray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stages an image pasted as raw clipboard data (copied from a browser, a screenshot tool,
|
||||||
|
/// or an image editor). The PNG is written to a per-paste temp folder so it flows through
|
||||||
|
/// the same path-based staging/encryption pipeline as regular files, and the temp file is
|
||||||
|
/// deleted once the message is sent.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleImagePasted(string channel, byte[] png)
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated || !_conn.IsConnected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_stagedAttachments.Count >= HubConstants.MaxAttachmentsPerMessage)
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A unique folder per paste keeps the Discord-style "image.png" display name
|
||||||
|
// while letting several pasted images coexist in one message.
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "EchoHub", "pasted", Guid.NewGuid().ToString("N")[..8]);
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var path = Path.Combine(dir, "image.png");
|
||||||
|
File.WriteAllBytes(path, png);
|
||||||
|
|
||||||
|
_tempPastedFiles.Add(path);
|
||||||
|
_stagedAttachments.Add(path);
|
||||||
|
RefreshStagingTray();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Staging a pasted clipboard image failed");
|
||||||
|
_mainWindow.ShowError($"Pasting image failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Best-effort removal of pasted-image temp files and their per-paste folders.</summary>
|
||||||
|
private static void CleanupPastedTempFiles(IReadOnlyList<string> files)
|
||||||
|
{
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(file);
|
||||||
|
if (Path.GetDirectoryName(file) is { } dir)
|
||||||
|
Directory.Delete(dir);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Debug(ex, "Could not delete pasted-image temp file {File}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the ASCII-art size picker (no argument) or sets it directly from "s"/"m"/"l" (or
|
/// 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.
|
/// small/medium/large). The choice is a persistent preference applied to attached images.
|
||||||
@@ -281,15 +568,27 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void SendStagedMessage(string channel, string content)
|
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;
|
var size = _config.DefaultAsciiSize;
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
|
// Locked E2E channel: block the send and keep the files staged for after the unlock
|
||||||
|
if (!await EnsureRoomUnlockedForSendAsync(channel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var staged = _stagedAttachments.ToList();
|
||||||
|
_stagedAttachments.Clear();
|
||||||
|
InvokeUI(RefreshStagingTray);
|
||||||
|
|
||||||
|
// Pasted clipboard images live in temp files; once this send owns them they are
|
||||||
|
// deleted whether the upload succeeds or fails (the tray is already cleared).
|
||||||
|
var tempFiles = staged.Where(_tempPastedFiles.Contains).ToList();
|
||||||
|
_tempPastedFiles.ExceptWith(tempFiles);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey);
|
||||||
|
|
||||||
var outgoing = new List<OutgoingAttachment>();
|
var outgoing = new List<OutgoingAttachment>();
|
||||||
foreach (var path in staged)
|
foreach (var path in staged)
|
||||||
outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size));
|
outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size));
|
||||||
@@ -299,6 +598,11 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
: content;
|
: content;
|
||||||
|
|
||||||
await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size);
|
await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CleanupPastedTempFiles(tempFiles);
|
||||||
|
}
|
||||||
}, "Send failed");
|
}, "Send failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +681,10 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
var history = await JoinChannelWithPasswordPromptAsync(channelName, password);
|
var history = await JoinChannelWithPasswordPromptAsync(channelName, password);
|
||||||
if (history is null) return; // user cancelled the password prompt
|
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);
|
||||||
@@ -404,6 +712,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
crypto = await _conn.Api!.GetChannelCryptoAsync(channelName);
|
crypto = await _conn.Api!.GetChannelCryptoAsync(channelName);
|
||||||
|
if (crypto is not null)
|
||||||
|
_conn.RoomKeys.MarkChannelEncrypted(channelName, crypto.IsEncrypted);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -425,15 +735,19 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
var outcome = await _conn.JoinChannelAsync(channelName, wirePassword);
|
var outcome = await _conn.JoinChannelAsync(channelName, wirePassword);
|
||||||
|
|
||||||
if (outcome.WrappedRoomKey is not null && !_conn.RoomKeys.HasKey(channelName))
|
if (outcome.WrappedRoomKey is not null)
|
||||||
{
|
{
|
||||||
if (kek is not null && RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, kek, out var roomKey))
|
// A typed passphrase always wins over the cache: unwrap the fresh envelope
|
||||||
|
// and overwrite any stale key (e.g. the channel was deleted and recreated
|
||||||
|
// under the same name — the old key would encrypt for nobody).
|
||||||
|
if (kek is not null && _conn.RoomKeys.TryStoreFromEnvelope(channelName, outcome.WrappedRoomKey, kek))
|
||||||
{
|
{
|
||||||
_conn.RoomKeys.StoreKey(channelName, roomKey);
|
lock (_declinedUnlocks) _declinedUnlocks.Remove(channelName);
|
||||||
// Re-fetch so history decrypts with the now-available room key
|
// Re-fetch so history decrypts with the now-available room key
|
||||||
return await _conn.GetHistoryAsync(channelName);
|
return await _conn.GetHistoryAsync(channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_conn.RoomKeys.HasKey(channelName))
|
||||||
return await UnlockRoomKeyAsync(channelName, outcome);
|
return await UnlockRoomKeyAsync(channelName, outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,12 +785,17 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
var passphrase = await prompt.Task;
|
var passphrase = await prompt.Task;
|
||||||
if (passphrase is null)
|
if (passphrase is null)
|
||||||
return outcome.History; // stays locked; placeholders render instead of content
|
{
|
||||||
|
// Stays locked; placeholders render instead of content. Remember the decline
|
||||||
|
// so reselecting the channel doesn't nag every time.
|
||||||
|
lock (_declinedUnlocks) _declinedUnlocks.Add(channelName);
|
||||||
|
return outcome.History;
|
||||||
|
}
|
||||||
|
|
||||||
var derived = RoomCrypto.DeriveKeys(passphrase, salt);
|
var derived = RoomCrypto.DeriveKeys(passphrase, salt);
|
||||||
if (RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, derived.KeyEncryptionKey, out var roomKey))
|
if (_conn.RoomKeys.TryStoreFromEnvelope(channelName, outcome.WrappedRoomKey, derived.KeyEncryptionKey))
|
||||||
{
|
{
|
||||||
_conn.RoomKeys.StoreKey(channelName, roomKey);
|
lock (_declinedUnlocks) _declinedUnlocks.Remove(channelName);
|
||||||
return await _conn.GetHistoryAsync(channelName);
|
return await _conn.GetHistoryAsync(channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +803,66 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when a channel is end-to-end encrypted, its room key isn't cached, and the
|
||||||
|
/// user hasn't already declined the unlock prompt this session.
|
||||||
|
/// </summary>
|
||||||
|
private bool NeedsUnlockPrompt(string channelName)
|
||||||
|
{
|
||||||
|
if (!_conn.RoomKeys.IsChannelEncrypted(channelName) || _conn.RoomKeys.HasKey(channelName))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
lock (_declinedUnlocks) return !_declinedUnlocks.Contains(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unlock flow for a channel that is already hub-joined (auto-join/reconnect discard
|
||||||
|
/// the key envelope): rejoin — members pass the gate without a password and the join
|
||||||
|
/// result carries the envelope — then run the passphrase prompt. On success the
|
||||||
|
/// decrypted history replaces the locked placeholders. Returns true when unlocked.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> UnlockTrackedChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var outcome = await _conn.JoinChannelAsync(channelName, null);
|
||||||
|
if (outcome.WrappedRoomKey is null)
|
||||||
|
return false; // not an E2E channel after all
|
||||||
|
|
||||||
|
var history = await UnlockRoomKeyAsync(channelName, outcome);
|
||||||
|
if (!_conn.RoomKeys.HasKey(channelName))
|
||||||
|
return false; // cancelled or never unwrapped
|
||||||
|
|
||||||
|
if (history is not null)
|
||||||
|
InvokeUI(() => _messageManager.LoadHistory(channelName, history));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Unlock flow failed for #{Channel}", channelName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send guard for end-to-end encrypted channels: without the cached room key nothing
|
||||||
|
/// may leave the client (it would be plaintext in a room others read as encrypted).
|
||||||
|
/// Offers the unlock prompt right away — even after an earlier decline, since the user
|
||||||
|
/// is actively trying to talk here. Returns true when sending is safe.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> EnsureRoomUnlockedForSendAsync(string channelName)
|
||||||
|
{
|
||||||
|
if (!_conn.RoomKeys.IsChannelEncrypted(channelName) || _conn.RoomKeys.HasKey(channelName))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (await UnlockTrackedChannelAsync(channelName))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
InvokeUI(() => _mainWindow.ShowError(
|
||||||
|
$"#{channelName} is end-to-end encrypted and locked — nothing was sent. Enter its passphrase to unlock it first."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Changes the current encrypted channel's passphrase: re-derives the join credential
|
/// 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
|
/// and re-wraps the cached room content key under the new passphrase. History is
|
||||||
@@ -546,6 +925,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)
|
||||||
@@ -605,6 +992,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;
|
||||||
@@ -833,6 +1260,15 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_conn.MessageDeleted += (channelName, messageId) =>
|
_conn.MessageDeleted += (channelName, messageId) =>
|
||||||
InvokeUI(() => _messageManager.RemoveMessage(channelName, messageId));
|
InvokeUI(() => _messageManager.RemoveMessage(channelName, messageId));
|
||||||
|
|
||||||
|
_conn.ChannelDeleted += channelName =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_conn.UntrackChannel(channelName);
|
||||||
|
_mainWindow.RemoveChannel(channelName);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
_conn.ChannelNuked += channelName =>
|
_conn.ChannelNuked += channelName =>
|
||||||
{
|
{
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
@@ -861,6 +1297,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_conn.Reconnected += () =>
|
_conn.Reconnected += () =>
|
||||||
{
|
{
|
||||||
lock (_channelUsersLock) _channelUsers.Clear();
|
lock (_channelUsersLock) _channelUsers.Clear();
|
||||||
|
lock (_declinedUnlocks) _declinedUnlocks.Clear();
|
||||||
RunAsync(
|
RunAsync(
|
||||||
async () => await _conn.RejoinChannelsAsync(),
|
async () => await _conn.RejoinChannelsAsync(),
|
||||||
"Failed to rejoin channels after reconnect");
|
"Failed to rejoin channels after reconnect");
|
||||||
@@ -909,14 +1346,30 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
_session.Username = result.Login.Username;
|
_session.Username = result.Login.Username;
|
||||||
|
lock (_declinedUnlocks) _declinedUnlocks.Clear();
|
||||||
|
|
||||||
|
// 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();
|
||||||
});
|
});
|
||||||
@@ -928,6 +1381,9 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
Log.Information("Disconnecting from server");
|
Log.Information("Disconnecting from server");
|
||||||
lock (_channelUsersLock) _channelUsers.Clear();
|
lock (_channelUsersLock) _channelUsers.Clear();
|
||||||
|
_pendingReply = null;
|
||||||
|
InvokeUI(() => _mainWindow.SetReplyingTo(null));
|
||||||
|
PersistLastReads();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -943,6 +1399,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 () =>
|
||||||
{
|
{
|
||||||
@@ -995,9 +1452,38 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
RunAsync(
|
// A pending reply only applies to a plain text message in its own channel
|
||||||
async () => await _conn.SendMessageAsync(channelName, content),
|
Guid? replyTo = _pendingReply is { } pending
|
||||||
"Send failed");
|
&& pending.Channel.Equals(channelName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? pending.MessageId : null;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
if (!await EnsureRoomUnlockedForSendAsync(channelName))
|
||||||
|
return;
|
||||||
|
await _conn.SendMessageAsync(channelName, content, replyTo);
|
||||||
|
if (replyTo is not null)
|
||||||
|
InvokeUI(ClearPendingReply);
|
||||||
|
}, "Send failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleReplyRequested(Guid messageId, string sender, string snippet)
|
||||||
|
{
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return;
|
||||||
|
|
||||||
|
_pendingReply = (channel, messageId);
|
||||||
|
if (snippet.Length > 40)
|
||||||
|
snippet = snippet[..40] + "…";
|
||||||
|
_mainWindow.SetReplyingTo($"{sender}: {snippet}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleReplyCancelRequested() => ClearPendingReply();
|
||||||
|
|
||||||
|
private void ClearPendingReply()
|
||||||
|
{
|
||||||
|
_pendingReply = null;
|
||||||
|
_mainWindow.SetReplyingTo(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleDeleteMessageRequested(Guid messageId)
|
private void HandleDeleteMessageRequested(Guid messageId)
|
||||||
@@ -1014,6 +1500,13 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
if (!_conn.IsConnected) return;
|
if (!_conn.IsConnected) return;
|
||||||
|
|
||||||
|
// A reply pending in the previous channel doesn't carry over
|
||||||
|
if (_pendingReply is { } pending && !pending.Channel.Equals(channelName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
InvokeUI(ClearPendingReply);
|
||||||
|
|
||||||
|
// Checkpoint read positions — the previous channel was just marked read
|
||||||
|
PersistLastReads();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
if (_conn.TrackChannel(channelName))
|
if (_conn.TrackChannel(channelName))
|
||||||
@@ -1026,6 +1519,16 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel));
|
InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A deliberate join cancels any earlier /leave exclusion
|
||||||
|
UpdateServerConfig(server =>
|
||||||
|
server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
}
|
||||||
|
else if (NeedsUnlockPrompt(channelName))
|
||||||
|
{
|
||||||
|
// Auto-join/reconnect already hub-joined this E2E channel but discarded the
|
||||||
|
// key envelope — selecting it is the user's cue to unlock it.
|
||||||
|
await UnlockTrackedChannelAsync(channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -1422,6 +1925,62 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
return tempPath;
|
return tempPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>File extensions the "[open]" action will hand to the OS image viewer for E2E rooms.</summary>
|
||||||
|
private static readonly HashSet<string> ImageOpenExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Views an image without saving it to the user's downloads. Plain channels open the
|
||||||
|
/// file's web URL in the default browser (the server serves files by capability URL, so
|
||||||
|
/// no auth token is needed). E2E-encrypted channels would render as ciphertext in a
|
||||||
|
/// browser, so the blob is downloaded, decrypted locally, and opened from a temp file.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleImageOpenRequested(string attachmentUrl, string fileName)
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated) return;
|
||||||
|
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
var isEncryptedRoom = !string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out _);
|
||||||
|
|
||||||
|
if (!isEncryptedRoom)
|
||||||
|
{
|
||||||
|
var webUrl = attachmentUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| attachmentUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? attachmentUrl
|
||||||
|
: $"{_conn.Api!.BaseUrl}/{attachmentUrl.TrimStart('/')}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(
|
||||||
|
new System.Diagnostics.ProcessStartInfo(webUrl) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to open image URL in browser: {Url}", webUrl);
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Couldn't open a browser — image URL: {webUrl}"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In E2E rooms the attachment kind is sender-declared, so only hand real image
|
||||||
|
// extensions to the OS viewer; anything else goes through the save path instead.
|
||||||
|
if (!ImageOpenExtensions.Contains(Path.GetExtension(fileName)))
|
||||||
|
{
|
||||||
|
HandleImageSaveRequested(attachmentUrl, fileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Decrypting {fileName}..."));
|
||||||
|
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
|
||||||
|
System.Diagnostics.Process.Start(psi);
|
||||||
|
}, "Failed to open image");
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleImageSaveRequested(string attachmentUrl, string fileName)
|
private void HandleImageSaveRequested(string attachmentUrl, string fileName)
|
||||||
{
|
{
|
||||||
if (!_conn.IsAuthenticated) return;
|
if (!_conn.IsAuthenticated) return;
|
||||||
@@ -1620,20 +2179,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();
|
||||||
|
|||||||
@@ -6,7 +6,19 @@ public record CommandResult(bool Handled, string? Message = null, bool IsError =
|
|||||||
|
|
||||||
public class CommandHandler
|
public class CommandHandler
|
||||||
{
|
{
|
||||||
public event Func<UserStatus, string?, Task>? OnSetStatus;
|
/// <summary>
|
||||||
|
/// Status update. A null status means "keep the current status"; a null message means
|
||||||
|
/// "keep the current message" and an empty message means "clear it". The orchestrator
|
||||||
|
/// resolves both against the session state.
|
||||||
|
/// </summary>
|
||||||
|
public event Func<UserStatus?, string?, Task>? OnSetStatus;
|
||||||
|
public event Func<string, Task>? OnSendAction;
|
||||||
|
public event Func<string, Task>? OnSendBanner;
|
||||||
|
public event Func<int?, int?, Task>? OnCreateInvite;
|
||||||
|
public event Func<Task>? OnListInvites;
|
||||||
|
public event Func<string, Task>? OnRevokeInvite;
|
||||||
|
public event Func<Task>? OnExportData;
|
||||||
|
public event Func<Task>? OnDeleteAccount;
|
||||||
public event Func<string, Task>? OnSetNick;
|
public event Func<string, Task>? OnSetNick;
|
||||||
public event Func<string, Task>? OnSetColor;
|
public event Func<string, Task>? OnSetColor;
|
||||||
public event Func<string, Task>? OnSetTheme;
|
public event Func<string, Task>? OnSetTheme;
|
||||||
@@ -21,6 +33,7 @@ public class CommandHandler
|
|||||||
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,6 +60,11 @@ public class CommandHandler
|
|||||||
return command switch
|
return command switch
|
||||||
{
|
{
|
||||||
"status" => await HandleStatus(args),
|
"status" => await HandleStatus(args),
|
||||||
|
"me" => await HandleMe(args),
|
||||||
|
"banner" => await HandleBanner(args),
|
||||||
|
"invite" => await HandleInvite(args),
|
||||||
|
"export" => await HandleExport(),
|
||||||
|
"deleteaccount" => await HandleDeleteAccount(),
|
||||||
"nick" => await HandleNick(args),
|
"nick" => await HandleNick(args),
|
||||||
"color" => await HandleColor(args),
|
"color" => await HandleColor(args),
|
||||||
"theme" => await HandleTheme(args),
|
"theme" => await HandleTheme(args),
|
||||||
@@ -62,6 +80,7 @@ public class CommandHandler
|
|||||||
"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),
|
||||||
@@ -76,12 +95,28 @@ public class CommandHandler
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const string StatusUsage =
|
||||||
|
"Usage: /status <online|away|dnd|invisible> or /status msg <text> (empty text clears it)";
|
||||||
|
|
||||||
private async Task<CommandResult> HandleStatus(string args)
|
private async Task<CommandResult> HandleStatus(string args)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(args))
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
return new CommandResult(true, "Usage: /status <online|away|dnd|invisible> or /status <message>", IsError: true);
|
return new CommandResult(true, StatusUsage, IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Trim().Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var statusArg = parts[0].ToLowerInvariant();
|
||||||
|
|
||||||
|
// /status msg <text> — set/clear the message, keep the current status
|
||||||
|
if (statusArg is "msg" or "message")
|
||||||
|
{
|
||||||
|
var message = parts.Length > 1 ? parts[1] : string.Empty;
|
||||||
|
if (OnSetStatus is not null)
|
||||||
|
await OnSetStatus(null, message);
|
||||||
|
return new CommandResult(true, message.Length > 0
|
||||||
|
? $"Status message set: {message}"
|
||||||
|
: "Status message cleared.");
|
||||||
|
}
|
||||||
|
|
||||||
var statusArg = args.ToLowerInvariant().Trim();
|
|
||||||
UserStatus? status = statusArg switch
|
UserStatus? status = statusArg switch
|
||||||
{
|
{
|
||||||
"online" => UserStatus.Online,
|
"online" => UserStatus.Online,
|
||||||
@@ -91,17 +126,92 @@ public class CommandHandler
|
|||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status.HasValue)
|
// Strict: anything else is an error — no silent "it became your status message"
|
||||||
{
|
if (!status.HasValue)
|
||||||
|
return new CommandResult(true, $"Unknown status '{parts[0]}'. {StatusUsage}", IsError: true);
|
||||||
|
|
||||||
|
if (parts.Length > 1)
|
||||||
|
return new CommandResult(true, StatusUsage, IsError: true);
|
||||||
|
|
||||||
if (OnSetStatus is not null)
|
if (OnSetStatus is not null)
|
||||||
await OnSetStatus(status.Value, null);
|
await OnSetStatus(status.Value, null);
|
||||||
return new CommandResult(true, $"Status set to {status.Value}");
|
return new CommandResult(true, $"Status set to {status.Value}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Treat as custom status message (keep current status)
|
private async Task<CommandResult> HandleMe(string args)
|
||||||
if (OnSetStatus is not null)
|
{
|
||||||
await OnSetStatus(UserStatus.Online, args);
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
return new CommandResult(true, $"Status message set: {args}");
|
return new CommandResult(true, "Usage: /me <action> (e.g. /me waves)", IsError: true);
|
||||||
|
|
||||||
|
if (OnSendAction is not null)
|
||||||
|
await OnSendAction(args.Trim());
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleBanner(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /banner <text> (letters, digits, basic punctuation)", IsError: true);
|
||||||
|
|
||||||
|
if (OnSendBanner is not null)
|
||||||
|
await OnSendBanner(args.Trim());
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleInvite(string args)
|
||||||
|
{
|
||||||
|
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
// /invite list
|
||||||
|
if (parts.Length > 0 && parts[0].Equals("list", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (OnListInvites is not null)
|
||||||
|
await OnListInvites();
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /invite revoke <code>
|
||||||
|
if (parts.Length > 0 && parts[0].Equals("revoke", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (parts.Length < 2)
|
||||||
|
return new CommandResult(true, "Usage: /invite revoke <code>", IsError: true);
|
||||||
|
if (OnRevokeInvite is not null)
|
||||||
|
await OnRevokeInvite(parts[1]);
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /invite [maxUses] [expiresHours] — defaults to a single-use, never-expiring code
|
||||||
|
int? maxUses = null, expiresHours = null;
|
||||||
|
if (parts.Length > 0)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(parts[0], out var uses) || uses < 1)
|
||||||
|
return new CommandResult(true, "Usage: /invite [maxUses] [expiresHours] | /invite list | /invite revoke <code>", IsError: true);
|
||||||
|
maxUses = uses;
|
||||||
|
}
|
||||||
|
if (parts.Length > 1)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(parts[1], out var hours) || hours < 1)
|
||||||
|
return new CommandResult(true, "Usage: /invite [maxUses] [expiresHours]", IsError: true);
|
||||||
|
expiresHours = hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OnCreateInvite is not null)
|
||||||
|
await OnCreateInvite(maxUses, expiresHours);
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleExport()
|
||||||
|
{
|
||||||
|
if (OnExportData is not null)
|
||||||
|
await OnExportData();
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleDeleteAccount()
|
||||||
|
{
|
||||||
|
if (OnDeleteAccount is not null)
|
||||||
|
await OnDeleteAccount();
|
||||||
|
return new CommandResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<CommandResult> HandleNick(string args)
|
private async Task<CommandResult> HandleNick(string args)
|
||||||
@@ -273,6 +383,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)
|
||||||
@@ -383,7 +500,9 @@ public class CommandHandler
|
|||||||
return new CommandResult(true, """
|
return new CommandResult(true, """
|
||||||
Available commands:
|
Available commands:
|
||||||
/status <online|away|dnd|invisible> - Set your status
|
/status <online|away|dnd|invisible> - Set your status
|
||||||
/status <message> - Set status message
|
/status msg <text> - Set status message (empty = clear)
|
||||||
|
/me <action> - Action message (* nick waves)
|
||||||
|
/banner <text> - Send text as an ASCII banner
|
||||||
/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
|
||||||
@@ -403,6 +522,10 @@ public class CommandHandler
|
|||||||
/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)
|
||||||
|
/export - Download everything the server stores about you
|
||||||
|
/deleteaccount - Permanently delete your account
|
||||||
|
(Tip: right-click a message and pick Reply to quote it; Esc cancels a pending reply.)
|
||||||
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+)
|
||||||
@@ -410,6 +533,8 @@ public class CommandHandler
|
|||||||
/mute <user> [minutes] - Mute a user (Mod+)
|
/mute <user> [minutes] - Mute a user (Mod+)
|
||||||
/unmute <user> - Unmute a user (Mod+)
|
/unmute <user> - Unmute a user (Mod+)
|
||||||
/role <user> <admin|mod|member> - Assign role (Admin+)
|
/role <user> <admin|mod|member> - Assign role (Admin+)
|
||||||
|
/invite [uses] [hours] - Create a registration invite code (Admin+)
|
||||||
|
/invite list | revoke <code> - Manage invite codes (Admin+)
|
||||||
/nuke - Clear channel history (Mod+)
|
/nuke - Clear channel history (Mod+)
|
||||||
/test-sound - Play notification sound
|
/test-sound - Play notification sound
|
||||||
/quit - Exit the app
|
/quit - Exit the app
|
||||||
|
|||||||
@@ -38,10 +38,23 @@ public class SavedServer
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cached room content keys for end-to-end encrypted channels on this server,
|
/// 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
|
/// keyed by channel name and encrypted at rest (see RoomKeyProtector; legacy
|
||||||
|
/// entries were plain base64). Like RefreshToken, these live only on the
|
||||||
/// user's machine — the server never sees them.
|
/// user's machine — the server never sees them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, string> ChannelKeys { get; set; } = [];
|
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,13 @@ public static class ConfigManager
|
|||||||
|
|
||||||
private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
|
private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
|
||||||
|
|
||||||
|
/// <summary>Directory holding the client config and local key material.</summary>
|
||||||
|
public static string ConfigDirectory => ConfigDir;
|
||||||
|
|
||||||
|
// 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,
|
||||||
@@ -16,6 +23,8 @@ public static class ConfigManager
|
|||||||
};
|
};
|
||||||
|
|
||||||
public static ClientConfig Load()
|
public static ClientConfig Load()
|
||||||
|
{
|
||||||
|
lock (FileLock)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -30,8 +39,11 @@ public static class ConfigManager
|
|||||||
return new ClientConfig();
|
return new ClientConfig();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void Save(ClientConfig config)
|
public static void Save(ClientConfig config)
|
||||||
|
{
|
||||||
|
lock (FileLock)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -44,8 +56,11 @@ public static class ConfigManager
|
|||||||
// Silently fail — config save is best-effort
|
// Silently fail — config save is best-effort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void SaveServer(SavedServer server)
|
public static void SaveServer(SavedServer server)
|
||||||
|
{
|
||||||
|
lock (FileLock)
|
||||||
{
|
{
|
||||||
var config = Load();
|
var config = Load();
|
||||||
var existing = config.SavedServers.FindIndex(s =>
|
var existing = config.SavedServers.FindIndex(s =>
|
||||||
@@ -58,8 +73,11 @@ public static class ConfigManager
|
|||||||
|
|
||||||
Save(config);
|
Save(config);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void RemoveServer(string url)
|
public static void RemoveServer(string url)
|
||||||
|
{
|
||||||
|
lock (FileLock)
|
||||||
{
|
{
|
||||||
var config = Load();
|
var config = Load();
|
||||||
config.SavedServers.RemoveAll(s =>
|
config.SavedServers.RemoveAll(s =>
|
||||||
@@ -68,3 +86,4 @@ public static class ConfigManager
|
|||||||
Save(config);
|
Save(config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
|
||||||
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5043" />
|
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5043" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ public sealed class ApiClient : IDisposable
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
|
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null, string? inviteCode = null)
|
||||||
{
|
{
|
||||||
var request = new RegisterRequest(username, password, displayName);
|
var request = new RegisterRequest(username, password, displayName, inviteCode);
|
||||||
using var response = await _http.PostAsJsonAsync("/api/auth/register", request);
|
using var response = await _http.PostAsJsonAsync("/api/auth/register", request);
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
@@ -271,6 +271,20 @@ public sealed class ApiClient : IDisposable
|
|||||||
return await response.Content.ReadFromJsonAsync<ChannelCryptoDto>();
|
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)
|
public async Task<ChannelDto?> RekeyChannelAsync(string channelName, RekeyChannelRequest request)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
@@ -298,6 +312,54 @@ public sealed class ApiClient : IDisposable
|
|||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Invites / Account ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<InviteDto?> CreateInviteAsync(int? maxUses = null, int? expiresInHours = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync("/api/invites", new CreateInviteRequest(maxUses, expiresInHours)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<InviteDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<InviteDto>> GetInvitesAsync()
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedGetAsync("/api/invites");
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<List<InviteDto>>() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RevokeInviteAsync(string code)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.DeleteAsync($"/api/invites/{Uri.EscapeDataString(code)}"));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Downloads the caller's full data export as raw JSON text.</summary>
|
||||||
|
public async Task<string> ExportMyDataAsync()
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedGetAsync("/api/users/me/export");
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadAsStringAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes the caller's account. The password re-confirms intent.</summary>
|
||||||
|
public async Task DeleteMyAccountAsync(string password)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.SendAsync(new HttpRequestMessage(HttpMethod.Delete, "/api/users/me")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new DeleteAccountRequest(password)),
|
||||||
|
}));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Moderation ────────────────────────────────────────────────────────
|
// ── Moderation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public async Task AssignRoleAsync(string username, ServerRole role)
|
public async Task AssignRoleAsync(string username, ServerRole role)
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using Serilog;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads raw image data from the OS clipboard (e.g. an image copied from a browser, or a
|
||||||
|
/// Win+Shift+S screenshot), which terminals cannot paste as text. Always returns PNG bytes:
|
||||||
|
/// clipboard PNG data is passed through, clipboard bitmaps (CF_DIB) are re-encoded.
|
||||||
|
/// </summary>
|
||||||
|
public static class ClipboardImage
|
||||||
|
{
|
||||||
|
private static readonly byte[] PngMagic = [0x89, 0x50, 0x4E, 0x47];
|
||||||
|
|
||||||
|
public static bool TryGetPng(out byte[] png)
|
||||||
|
{
|
||||||
|
png = [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
return TryGetWindows(out png);
|
||||||
|
if (OperatingSystem.IsLinux())
|
||||||
|
return TryGetLinux(out png);
|
||||||
|
if (OperatingSystem.IsMacOS())
|
||||||
|
return TryGetMacOS(out png);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Reading an image from the clipboard failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPng(byte[] data) =>
|
||||||
|
data.Length > PngMagic.Length && data.AsSpan(0, PngMagic.Length).SequenceEqual(PngMagic);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts clipboard DIB bytes (a BITMAPINFOHEADER/V4/V5 + optional palette/masks + pixel
|
||||||
|
/// data, i.e. a .bmp file without its 14-byte file header) to PNG. Returns null when the
|
||||||
|
/// data is malformed or not decodable as a bitmap.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[]? DibToPng(byte[] dib)
|
||||||
|
{
|
||||||
|
if (dib.Length < 40)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var headerSize = BitConverter.ToInt32(dib, 0);
|
||||||
|
if (headerSize < 40 || headerSize > dib.Length)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var bitCount = BitConverter.ToUInt16(dib, 14);
|
||||||
|
var compression = BitConverter.ToUInt32(dib, 16);
|
||||||
|
var clrUsed = BitConverter.ToUInt32(dib, 32);
|
||||||
|
|
||||||
|
// Pixel data offset: file header + info header + color masks + palette.
|
||||||
|
// BI_BITFIELDS masks follow a plain 40-byte header; larger headers embed them.
|
||||||
|
var maskBytes = headerSize == 40 && compression == 3 ? 12
|
||||||
|
: headerSize == 40 && compression == 6 ? 16
|
||||||
|
: 0;
|
||||||
|
var paletteEntries = clrUsed != 0 ? clrUsed
|
||||||
|
: bitCount <= 8 ? 1u << bitCount
|
||||||
|
: 0u;
|
||||||
|
var pixelOffset = (uint)(14 + headerSize + maskBytes) + paletteEntries * 4;
|
||||||
|
|
||||||
|
var bmp = new byte[14 + dib.Length];
|
||||||
|
bmp[0] = (byte)'B';
|
||||||
|
bmp[1] = (byte)'M';
|
||||||
|
BitConverter.TryWriteBytes(bmp.AsSpan(2), (uint)bmp.Length);
|
||||||
|
BitConverter.TryWriteBytes(bmp.AsSpan(10), pixelOffset);
|
||||||
|
dib.CopyTo(bmp, 14);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var image = Image.Load(bmp);
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
image.SaveAsPng(ms);
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ImageFormatException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Clipboard DIB could not be decoded as a bitmap");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Windows: "PNG" / "image/png" registered formats, then CF_DIB ─────────
|
||||||
|
|
||||||
|
private const uint CfDib = 8;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static bool TryGetWindows(out byte[] png)
|
||||||
|
{
|
||||||
|
png = [];
|
||||||
|
|
||||||
|
// Browsers register a "PNG" (Chromium) or "image/png" (some apps) clipboard format
|
||||||
|
// preserving transparency; CF_DIB is synthesized by Windows for everything else
|
||||||
|
// (screenshots, image editors), so together these cover all image sources.
|
||||||
|
var pngFormat = RegisterClipboardFormatW("PNG");
|
||||||
|
var mimeFormat = RegisterClipboardFormatW("image/png");
|
||||||
|
|
||||||
|
var hasAny = (pngFormat != 0 && IsClipboardFormatAvailable(pngFormat))
|
||||||
|
|| (mimeFormat != 0 && IsClipboardFormatAvailable(mimeFormat))
|
||||||
|
|| IsClipboardFormatAvailable(CfDib);
|
||||||
|
if (!hasAny)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var opened = false;
|
||||||
|
for (var attempt = 0; attempt < 5 && !opened; attempt++)
|
||||||
|
opened = OpenClipboard(IntPtr.Zero);
|
||||||
|
if (!opened)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var format in new[] { pngFormat, mimeFormat })
|
||||||
|
{
|
||||||
|
if (format == 0 || !IsClipboardFormatAvailable(format))
|
||||||
|
continue;
|
||||||
|
var data = ReadHGlobal(GetClipboardData(format));
|
||||||
|
if (data is not null && IsPng(data))
|
||||||
|
{
|
||||||
|
png = data;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsClipboardFormatAvailable(CfDib)
|
||||||
|
&& ReadHGlobal(GetClipboardData(CfDib)) is { } dib
|
||||||
|
&& DibToPng(dib) is { } converted)
|
||||||
|
{
|
||||||
|
png = converted;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CloseClipboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static byte[]? ReadHGlobal(IntPtr handle)
|
||||||
|
{
|
||||||
|
if (handle == IntPtr.Zero)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var ptr = GlobalLock(handle);
|
||||||
|
if (ptr == IntPtr.Zero)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var size = (int)GlobalSize(handle);
|
||||||
|
if (size <= 0)
|
||||||
|
return null;
|
||||||
|
var data = new byte[size];
|
||||||
|
Marshal.Copy(ptr, data, 0, size);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
GlobalUnlock(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern uint RegisterClipboardFormatW(string lpszFormat);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern IntPtr GlobalLock(IntPtr hMem);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool GlobalUnlock(IntPtr hMem);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern nuint GlobalSize(IntPtr hMem);
|
||||||
|
|
||||||
|
// ── Linux: image/png via wl-paste or xclip ────────────────────────────────
|
||||||
|
|
||||||
|
[SupportedOSPlatform("linux")]
|
||||||
|
private static bool TryGetLinux(out byte[] png)
|
||||||
|
{
|
||||||
|
png = [];
|
||||||
|
var data = RunForBytes("wl-paste", ["--type", "image/png"])
|
||||||
|
?? RunForBytes("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]);
|
||||||
|
if (data is null || !IsPng(data))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
png = data;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── macOS: pngpaste (brew install pngpaste), when present ────────────────
|
||||||
|
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
|
private static bool TryGetMacOS(out byte[] png)
|
||||||
|
{
|
||||||
|
png = [];
|
||||||
|
var data = RunForBytes("pngpaste", ["-"]);
|
||||||
|
if (data is null || !IsPng(data))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
png = data;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[]? RunForBytes(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;
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
process.StandardOutput.BaseStream.CopyTo(ms);
|
||||||
|
process.WaitForExit(2000);
|
||||||
|
return process.ExitCode == 0 && ms.Length > 0 ? ms.ToArray() : 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.
|
||||||
@@ -44,6 +46,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
public event Action<string, string?>? UserBanned;
|
public event Action<string, string?>? UserBanned;
|
||||||
public event Action<string>? ForceDisconnected;
|
public event Action<string>? ForceDisconnected;
|
||||||
public event Action<string, Guid>? MessageDeleted;
|
public event Action<string, Guid>? MessageDeleted;
|
||||||
|
public event Action<string>? ChannelDeleted;
|
||||||
public event Action<string>? ChannelNuked;
|
public event Action<string>? ChannelNuked;
|
||||||
public event Action<ChannelDto>? ChannelUpdated;
|
public event Action<ChannelDto>? ChannelUpdated;
|
||||||
public event Action<string>? Error;
|
public event Action<string>? Error;
|
||||||
@@ -75,7 +78,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
else if (info.IsRegister)
|
else if (info.IsRegister)
|
||||||
{
|
{
|
||||||
loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
|
loginResponse = await _apiClient.RegisterAsync(
|
||||||
|
info.Username, info.Password, info.DisplayName, info.InviteCode);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -109,24 +113,59 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
await _connection.ConnectAsync();
|
await _connection.ConnectAsync();
|
||||||
|
|
||||||
var channels = await _apiClient.GetChannelsAsync();
|
var channels = await _apiClient.GetChannelsAsync();
|
||||||
onStatus("Connected");
|
|
||||||
|
// Known E2E channels — senders consult this so a client without the room
|
||||||
|
// key never emits plaintext into an encrypted room
|
||||||
|
foreach (var channel in channels)
|
||||||
|
_roomKeys.MarkChannelEncrypted(channel.Name, channel.IsEncrypted);
|
||||||
|
|
||||||
// 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
|
||||||
{
|
{
|
||||||
@@ -206,8 +245,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
|
|
||||||
// ── Delegate Operations ───────────────────────────────────────────────
|
// ── Delegate Operations ───────────────────────────────────────────────
|
||||||
|
|
||||||
public Task SendMessageAsync(string channel, string content) =>
|
public Task SendMessageAsync(string channel, string content, Guid? replyToMessageId = null) =>
|
||||||
_connection?.SendMessageAsync(channel, content)
|
_connection?.SendMessageAsync(channel, content, replyToMessageId)
|
||||||
?? throw new InvalidOperationException("Not connected");
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
|
public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
|
||||||
@@ -236,11 +275,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
|
||||||
|
try
|
||||||
|
{
|
||||||
await _connection.JoinChannelAsync(channel);
|
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 ──────────────────────────────────────────────
|
||||||
@@ -255,8 +302,13 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
connection.OnUserBanned += (user, reason) => UserBanned?.Invoke(user, reason);
|
connection.OnUserBanned += (user, reason) => UserBanned?.Invoke(user, reason);
|
||||||
connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason);
|
connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason);
|
||||||
connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id);
|
connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id);
|
||||||
|
connection.OnChannelDeleted += ch => ChannelDeleted?.Invoke(ch);
|
||||||
connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch);
|
connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch);
|
||||||
connection.OnChannelUpdated += ch => ChannelUpdated?.Invoke(ch);
|
connection.OnChannelUpdated += ch =>
|
||||||
|
{
|
||||||
|
_roomKeys.MarkChannelEncrypted(ch.Name, ch.IsEncrypted);
|
||||||
|
ChannelUpdated?.Invoke(ch);
|
||||||
|
};
|
||||||
connection.OnError += msg => Error?.Invoke(msg);
|
connection.OnError += msg => Error?.Invoke(msg);
|
||||||
connection.OnConnectionStateChanged += status => ConnectionStatusChanged?.Invoke(status);
|
connection.OnConnectionStateChanged += status => ConnectionStatusChanged?.Invoke(status);
|
||||||
connection.OnReconnected += () => Reconnected?.Invoke();
|
connection.OnReconnected += () => Reconnected?.Invoke();
|
||||||
@@ -268,8 +320,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;
|
||||||
@@ -277,6 +328,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()
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ public sealed class ChannelPasswordRequiredException : Exception
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when sending into an end-to-end encrypted channel whose room key isn't cached:
|
||||||
|
/// without the key the message would leave the client as plaintext, which must never happen.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoomLockedException : Exception
|
||||||
|
{
|
||||||
|
public string ChannelName { get; }
|
||||||
|
|
||||||
|
public RoomLockedException(string channelName)
|
||||||
|
: base($"#{channelName} is end-to-end encrypted and locked — enter its passphrase to unlock it before sending.")
|
||||||
|
{
|
||||||
|
ChannelName = channelName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class EchoHubConnection : IAsyncDisposable
|
public sealed class EchoHubConnection : IAsyncDisposable
|
||||||
{
|
{
|
||||||
public const string LockedMessagePlaceholder =
|
public const string LockedMessagePlaceholder =
|
||||||
@@ -43,6 +58,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
public event Action<string, string, string?>? OnUserKicked;
|
public event Action<string, string, string?>? OnUserKicked;
|
||||||
public event Action<string, string?>? OnUserBanned;
|
public event Action<string, string?>? OnUserBanned;
|
||||||
public event Action<string, Guid>? OnMessageDeleted;
|
public event Action<string, Guid>? OnMessageDeleted;
|
||||||
|
public event Action<string>? OnChannelDeleted;
|
||||||
public event Action<string>? OnChannelNuked;
|
public event Action<string>? OnChannelNuked;
|
||||||
public event Action<string>? OnForceDisconnect;
|
public event Action<string>? OnForceDisconnect;
|
||||||
public event Action<string>? OnError;
|
public event Action<string>? OnError;
|
||||||
@@ -129,6 +145,11 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
OnMessageDeleted?.Invoke(channelName, messageId);
|
OnMessageDeleted?.Invoke(channelName, messageId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelDeleted), channelName =>
|
||||||
|
{
|
||||||
|
OnChannelDeleted?.Invoke(channelName);
|
||||||
|
});
|
||||||
|
|
||||||
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName =>
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName =>
|
||||||
{
|
{
|
||||||
OnChannelNuked?.Invoke(channelName);
|
OnChannelNuked?.Invoke(channelName);
|
||||||
@@ -167,6 +188,8 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected.");
|
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.");
|
||||||
}
|
}
|
||||||
|
if (result.WrappedRoomKey is not null)
|
||||||
|
_roomKeys.MarkChannelEncrypted(channelName, true);
|
||||||
return new JoinOutcome(DecryptMessages(result.History), result.EncryptionSalt, result.WrappedRoomKey);
|
return new JoinOutcome(DecryptMessages(result.History), result.EncryptionSalt, result.WrappedRoomKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,14 +198,16 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
await _connection.InvokeAsync("LeaveChannel", channelName);
|
await _connection.InvokeAsync("LeaveChannel", channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendMessageAsync(string channelName, string content)
|
public async Task SendMessageAsync(string channelName, string content, Guid? replyToMessageId = null)
|
||||||
{
|
{
|
||||||
// Room layer first (end-to-end, server can't read), then transport encryption
|
// Room layer first (end-to-end, server can't read), then transport encryption
|
||||||
if (_roomKeys.TryGetKey(channelName, out var roomKey))
|
if (_roomKeys.TryGetKey(channelName, out var roomKey))
|
||||||
content = RoomCrypto.EncryptText(content, roomKey);
|
content = RoomCrypto.EncryptText(content, roomKey);
|
||||||
|
else if (_roomKeys.IsChannelEncrypted(channelName))
|
||||||
|
throw new RoomLockedException(channelName); // never fall through to plaintext
|
||||||
|
|
||||||
var encrypted = _encryption.Encrypt(content);
|
var encrypted = _encryption.Encrypt(content);
|
||||||
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
|
await _connection.InvokeAsync("SendMessage", channelName, encrypted, replyToMessageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
|
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
|
||||||
@@ -225,7 +250,12 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return message with { Content = content, Attachments = attachments };
|
// Reply snippets are encrypted exactly like message content
|
||||||
|
var replyTo = message.ReplyTo is { } reply
|
||||||
|
? reply with { Content = DecryptField(reply.Content, roomKey) ?? LockedMessagePlaceholder }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return message with { Content = content, Attachments = attachments, ReplyTo = replyTo };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using EchoHub.Core.Security;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encrypts cached room content keys at rest so the client config never holds them as
|
||||||
|
/// plain base64. Windows uses DPAPI (current-user scope, format prefix "dp1:"). On other
|
||||||
|
/// platforms the keys are AES-GCM encrypted with a per-user master key file stored next
|
||||||
|
/// to the config with 0600 permissions (prefix "k1:") — without an OS keychain that is
|
||||||
|
/// file-permission-level protection, not zero-knowledge: anyone who can read both the
|
||||||
|
/// config and the key file can recover the room keys. Values with no recognized prefix
|
||||||
|
/// are legacy plain-base64 keys from older clients; they load once and are re-encrypted.
|
||||||
|
/// The room passphrase itself is never stored in any form.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoomKeyProtector
|
||||||
|
{
|
||||||
|
public const string DpapiPrefix = "dp1:";
|
||||||
|
public const string KeyFilePrefix = "k1:";
|
||||||
|
|
||||||
|
private const string KeyFileName = "roomkeys.key";
|
||||||
|
private const int MasterKeySizeBytes = 32;
|
||||||
|
private const int RoomKeySizeBytes = 32;
|
||||||
|
|
||||||
|
private readonly string _keyFilePath;
|
||||||
|
private readonly bool _useDpapi;
|
||||||
|
private readonly Lock _lock = new();
|
||||||
|
private byte[]? _masterKey;
|
||||||
|
|
||||||
|
/// <param name="keyDirectory">Directory holding the master key file (the client config dir).</param>
|
||||||
|
/// <param name="useDpapi">Overrides the platform default (DPAPI on Windows) — for tests.</param>
|
||||||
|
public RoomKeyProtector(string keyDirectory, bool? useDpapi = null)
|
||||||
|
{
|
||||||
|
_keyFilePath = Path.Combine(keyDirectory, KeyFileName);
|
||||||
|
_useDpapi = useDpapi ?? OperatingSystem.IsWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encrypts a room key for storage in the config file.</summary>
|
||||||
|
public string Protect(byte[] roomKey)
|
||||||
|
{
|
||||||
|
if (_useDpapi && OperatingSystem.IsWindows())
|
||||||
|
return DpapiPrefix + Convert.ToBase64String(
|
||||||
|
ProtectedData.Protect(roomKey, null, DataProtectionScope.CurrentUser));
|
||||||
|
|
||||||
|
return KeyFilePrefix + Convert.ToBase64String(RoomCrypto.EncryptBytes(roomKey, GetMasterKey()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decrypts a stored value back into a room key. <paramref name="wasLegacy"/> is true when
|
||||||
|
/// the value was an unencrypted legacy entry that should be re-persisted via
|
||||||
|
/// <see cref="Protect"/>. Returns false for unreadable values (wrong user/machine, missing
|
||||||
|
/// or regenerated key file, malformed data) — the caller drops the entry and the user can
|
||||||
|
/// recover it by re-entering the passphrase.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryUnprotect(string stored, out byte[] roomKey, out bool wasLegacy)
|
||||||
|
{
|
||||||
|
roomKey = [];
|
||||||
|
wasLegacy = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (stored.StartsWith(DpapiPrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
return false; // config copied from a Windows machine
|
||||||
|
|
||||||
|
roomKey = ProtectedData.Unprotect(
|
||||||
|
Convert.FromBase64String(stored[DpapiPrefix.Length..]), null, DataProtectionScope.CurrentUser);
|
||||||
|
return roomKey.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stored.StartsWith(KeyFilePrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (!File.Exists(_keyFilePath))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
roomKey = RoomCrypto.DecryptBytes(
|
||||||
|
Convert.FromBase64String(stored[KeyFilePrefix.Length..]), GetMasterKey());
|
||||||
|
return roomKey.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No recognized prefix — legacy plain-base64 room key from a pre-encryption client
|
||||||
|
roomKey = Convert.FromBase64String(stored);
|
||||||
|
wasLegacy = true;
|
||||||
|
return roomKey.Length == RoomKeySizeBytes;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or CryptographicException
|
||||||
|
or IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] GetMasterKey()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_masterKey is not null)
|
||||||
|
return _masterKey;
|
||||||
|
|
||||||
|
if (File.Exists(_keyFilePath))
|
||||||
|
{
|
||||||
|
var existing = File.ReadAllBytes(_keyFilePath);
|
||||||
|
if (existing.Length == MasterKeySizeBytes)
|
||||||
|
return _masterKey = existing;
|
||||||
|
|
||||||
|
Log.Warning("Room-key master key file has unexpected size — regenerating (previously cached keys become unreadable)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = RandomNumberGenerator.GetBytes(MasterKeySizeBytes);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_keyFilePath)!);
|
||||||
|
File.WriteAllBytes(_keyFilePath, key);
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
File.SetUnixFileMode(_keyFilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||||
|
|
||||||
|
return _masterKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using EchoHub.Client.Config;
|
using EchoHub.Client.Config;
|
||||||
|
using EchoHub.Core.Security;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
namespace EchoHub.Client.Services;
|
namespace EchoHub.Client.Services;
|
||||||
@@ -6,14 +7,28 @@ namespace EchoHub.Client.Services;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Holds room content keys for end-to-end encrypted channels: in-memory for the
|
/// 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)
|
/// 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.
|
/// so users don't retype the passphrase every launch. Keys never leave this machine
|
||||||
|
/// and are encrypted at rest by <see cref="RoomKeyProtector"/>. Also tracks which
|
||||||
|
/// channels are known to be end-to-end encrypted, so senders can refuse to emit
|
||||||
|
/// plaintext into a room whose key isn't cached yet.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RoomKeyStore
|
public sealed class RoomKeyStore
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, byte[]> _keys = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, byte[]> _keys = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> _encryptedChannels = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly RoomKeyProtector _protector;
|
||||||
private readonly Lock _lock = new();
|
private readonly Lock _lock = new();
|
||||||
private string? _serverUrl;
|
private string? _serverUrl;
|
||||||
|
|
||||||
|
public RoomKeyStore() : this(new RoomKeyProtector(ConfigManager.ConfigDirectory))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public RoomKeyStore(RoomKeyProtector protector)
|
||||||
|
{
|
||||||
|
_protector = protector;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Binds the store to a server and loads that server's cached keys from config.</summary>
|
/// <summary>Binds the store to a server and loads that server's cached keys from config.</summary>
|
||||||
public void LoadForServer(string serverUrl)
|
public void LoadForServer(string serverUrl)
|
||||||
{
|
{
|
||||||
@@ -21,21 +36,34 @@ public sealed class RoomKeyStore
|
|||||||
{
|
{
|
||||||
_serverUrl = serverUrl;
|
_serverUrl = serverUrl;
|
||||||
_keys.Clear();
|
_keys.Clear();
|
||||||
|
_encryptedChannels.Clear();
|
||||||
|
|
||||||
var server = FindServer(ConfigManager.Load(), serverUrl);
|
var server = FindServer(ConfigManager.Load(), serverUrl);
|
||||||
if (server is null) return;
|
if (server is null) return;
|
||||||
|
|
||||||
foreach (var (channel, base64) in server.ChannelKeys)
|
var legacyFound = false;
|
||||||
|
foreach (var (channel, stored) in server.ChannelKeys)
|
||||||
{
|
{
|
||||||
try
|
if (_protector.TryUnprotect(stored, out var key, out var wasLegacy))
|
||||||
{
|
{
|
||||||
_keys[channel] = Convert.FromBase64String(base64);
|
_keys[channel] = key;
|
||||||
|
legacyFound |= wasLegacy;
|
||||||
}
|
}
|
||||||
catch (FormatException)
|
else
|
||||||
{
|
{
|
||||||
Log.Warning("Ignoring malformed cached room key for #{Channel}", channel);
|
Log.Warning("Ignoring unreadable cached room key for #{Channel}", channel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-way upgrade: legacy plain-base64 entries get re-persisted encrypted
|
||||||
|
// (unreadable entries drop out — the unlock prompt recovers those rooms).
|
||||||
|
if (legacyFound)
|
||||||
|
Persist(s =>
|
||||||
|
{
|
||||||
|
s.ChannelKeys.Clear();
|
||||||
|
foreach (var (channel, key) in _keys)
|
||||||
|
s.ChannelKeys[channel] = _protector.Protect(key);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,10 +90,26 @@ public sealed class RoomKeyStore
|
|||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_keys[channelName] = key;
|
_keys[channelName] = key;
|
||||||
Persist(server => server.ChannelKeys[channelName] = Convert.ToBase64String(key));
|
_encryptedChannels.Add(channelName);
|
||||||
|
Persist(server => server.ChannelKeys[channelName] = _protector.Protect(key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unwraps a fresh key envelope and caches the key, overwriting any stale cached key
|
||||||
|
/// (e.g. the channel was deleted and recreated under the same name, so the old key
|
||||||
|
/// would encrypt messages nobody else can read). Returns false when the KEK doesn't
|
||||||
|
/// open the envelope — the cache is left untouched.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryStoreFromEnvelope(string channelName, string wrappedRoomKey, byte[] kek)
|
||||||
|
{
|
||||||
|
if (!RoomCrypto.TryUnwrapRoomKey(wrappedRoomKey, kek, out var roomKey))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
StoreKey(channelName, roomKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public void RemoveKey(string channelName)
|
public void RemoveKey(string channelName)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -75,11 +119,36 @@ public sealed class RoomKeyStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records whether a channel is end-to-end encrypted (from channel listings, crypto
|
||||||
|
/// metadata, or join outcomes). Senders consult this to block plaintext into rooms
|
||||||
|
/// whose key isn't cached.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkChannelEncrypted(string channelName, bool isEncrypted)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (isEncrypted)
|
||||||
|
_encryptedChannels.Add(channelName);
|
||||||
|
else
|
||||||
|
_encryptedChannels.Remove(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsChannelEncrypted(string channelName)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _encryptedChannels.Contains(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_keys.Clear();
|
_keys.Clear();
|
||||||
|
_encryptedChannels.Clear();
|
||||||
_serverUrl = null;
|
_serverUrl = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,66 +63,97 @@ 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).
|
||||||
|
_pendingVersion = version;
|
||||||
|
PendingUpdate = ApplyUpdateAsync;
|
||||||
|
_app.RequestStop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
/// <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()
|
||||||
{
|
{
|
||||||
// Create backup before the update starts
|
_applying = true;
|
||||||
|
|
||||||
|
// 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
|
try
|
||||||
{
|
{
|
||||||
_progressDialog?.UpdateProgress(0f, "Creating backup...");
|
Console.WriteLine("Creating backup...");
|
||||||
UpdateBackupService.CreateBackup();
|
UpdateBackupService.CreateBackup();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Error(ex, "Failed to create pre-update backup");
|
Log.Error(ex, "Failed to create pre-update backup");
|
||||||
|
Console.WriteLine($"Warning: could not create a backup ({ex.Message}). Continuing without one.");
|
||||||
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...");
|
Console.WriteLine("Downloading update...");
|
||||||
await _updater.UpdateAsync();
|
await _updater.UpdateAsync(); // download → extract → restart → Environment.Exit(0)
|
||||||
});
|
}
|
||||||
|
|
||||||
_progressDialog.Show();
|
private const int BarWidth = 28;
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
|
private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
|
||||||
{
|
{
|
||||||
var fraction = progressPercentage.HasValue ? (float)(progressPercentage.Value / 100.0) : 0f;
|
// Before the TUI is torn down (i.e. during a check) there is no progress surface; the
|
||||||
var statusText = $"{step}: {itemsProcessed}/{totalItems ?? 0} ({progressPercentage ?? 0:F0}%)";
|
// real work happens headless after shutdown, so draw a progress bar on the console.
|
||||||
|
if (!_applying)
|
||||||
|
return;
|
||||||
|
|
||||||
if (!progressPercentage.HasValue)
|
// Finish the previous step's line so each step keeps its completed bar.
|
||||||
|
if (step != _lastStep)
|
||||||
{
|
{
|
||||||
statusText = $"{step}...";
|
if (_lastStep != (UpdateStep)(-1))
|
||||||
|
Console.WriteLine();
|
||||||
|
_lastStep = step;
|
||||||
}
|
}
|
||||||
|
|
||||||
_progressDialog?.UpdateProgress(fraction, statusText);
|
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)
|
||||||
{
|
{
|
||||||
@@ -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,
|
|
||||||
"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
|
try
|
||||||
{
|
{
|
||||||
UpdateBackupService.RestoreBackup();
|
UpdateBackupService.RestoreBackup(); // calls Environment.Exit(0)
|
||||||
// RestoreBackup calls Environment.Exit(0)
|
|
||||||
}
|
}
|
||||||
catch (Exception restoreEx)
|
catch (Exception restoreEx)
|
||||||
{
|
{
|
||||||
Log.Error(restoreEx, "Backup restoration failed");
|
Log.Error(restoreEx, "Backup restoration failed");
|
||||||
MessageBox.ErrorQuery(_app, "Restore Failed",
|
Console.Error.WriteLine($"Restore failed: {restoreEx.Message}. Re-download EchoHub to recover.");
|
||||||
$"Could not restore backup: {restoreEx.Message}\n\nYou may need to re-download the application.", "OK");
|
Environment.Exit(1);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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,14 @@ 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"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -475,6 +483,14 @@ public static class ThemeManager
|
|||||||
Background = "None",
|
Background = "None",
|
||||||
FocusForeground = "DarkGray",
|
FocusForeground = "DarkGray",
|
||||||
FocusBackground = "None"
|
FocusBackground = "None"
|
||||||
|
},
|
||||||
|
// Softer gray borders against light terminal backgrounds
|
||||||
|
Border = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "#8F8F8F",
|
||||||
|
Background = "None",
|
||||||
|
FocusForeground = "#6E6E6E",
|
||||||
|
FocusBackground = "None"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -546,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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ using Attribute = Terminal.Gui.Drawing.Attribute;
|
|||||||
|
|
||||||
namespace EchoHub.Client.UI.Chat;
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>An action a click on an attachment line can trigger.</summary>
|
||||||
|
public enum AttachmentAction
|
||||||
|
{
|
||||||
|
OpenImage,
|
||||||
|
SaveImage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Inclusive column range on a chat line that triggers an attachment action when clicked.</summary>
|
||||||
|
public readonly record struct AttachmentActionSpan(int StartCol, int EndCol, AttachmentAction Action);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single line in the chat, composed of colored segments.
|
/// A single line in the chat, composed of colored segments.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -20,9 +30,42 @@ public partial class ChatLine
|
|||||||
public string? AttachmentFileName { get; set; }
|
public string? AttachmentFileName { get; set; }
|
||||||
public AttachmentKind? AttachmentKind { get; set; }
|
public AttachmentKind? AttachmentKind { get; set; }
|
||||||
public string? SenderUsername { get; set; }
|
public string? SenderUsername { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set on a reply's quote line: activating the line jumps to this message
|
||||||
|
/// if it is in the loaded history.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? JumpToMessageId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clickable sub-line targets (e.g. the "[open]" and "[save original]" brackets under an
|
||||||
|
/// image). Columns are relative to the unwrapped line, so only the first wrapped line
|
||||||
|
/// keeps them. Null means the whole line uses the kind's default action.
|
||||||
|
/// </summary>
|
||||||
|
public List<AttachmentActionSpan>? ActionSpans { 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 +86,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 +139,13 @@ public partial class ChatLine
|
|||||||
}
|
}
|
||||||
|
|
||||||
var segments = new List<ChatSegment>();
|
var segments = new List<ChatSegment>();
|
||||||
if (!firstLine && continuationIndent > 0)
|
if (!firstLine)
|
||||||
|
{
|
||||||
|
if (prefixSegments is not null)
|
||||||
|
segments.AddRange(prefixSegments);
|
||||||
|
else if (continuationIndent > 0)
|
||||||
segments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
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,6 +173,7 @@ 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;
|
||||||
@@ -127,8 +181,14 @@ public partial class ChatLine
|
|||||||
wrapped.AttachmentKind = AttachmentKind;
|
wrapped.AttachmentKind = AttachmentKind;
|
||||||
wrapped.MessageId = MessageId;
|
wrapped.MessageId = MessageId;
|
||||||
wrapped.SenderUsername = SenderUsername;
|
wrapped.SenderUsername = SenderUsername;
|
||||||
|
wrapped.IsMention = IsMention;
|
||||||
|
wrapped.JumpToMessageId = JumpToMessageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Span columns only line up with the first wrapped line; later lines fall
|
||||||
|
// back to the kind's default action.
|
||||||
|
results[0].ActionSpans = ActionSpans;
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Client.UI.Helpers;
|
using EchoHub.Client.UI.Helpers;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using Terminal.Gui.Drawing;
|
using Terminal.Gui.Drawing;
|
||||||
@@ -15,8 +17,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 +47,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 +82,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 +117,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 +163,20 @@ public sealed class ChatMessageManager
|
|||||||
_channelMessages[channelName] = messages;
|
_channelMessages[channelName] = messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
var time = FormatDateTime(DateTimeOffset.Now);
|
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 +188,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 = FormatDateTime(DateTimeOffset.Now);
|
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 +224,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,16 +234,92 @@ 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 (channelName == _currentChannel)
|
if (lastDate is { } date)
|
||||||
|
_channelLastDate[channelName] = date;
|
||||||
|
else
|
||||||
|
_channelLastDate.Remove(channelName);
|
||||||
|
|
||||||
MessagesChanged?.Invoke(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)
|
||||||
|
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)
|
||||||
|
|| (m.ReplyTo is { } reply && reply.SenderUsername.Equals(_currentUser, StringComparison.OrdinalIgnoreCase))))
|
||||||
|
_mentionChannels.Add(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Prepend older messages at the front of a channel's buffer, skipping any that are already present.
|
/// Prepend older messages at the front of a channel's buffer, skipping any that are already present.
|
||||||
/// Fires <see cref="HistoryPrepended"/> when new lines are actually inserted.
|
/// Fires <see cref="HistoryPrepended"/> when new lines are actually inserted.
|
||||||
@@ -200,14 +334,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 +369,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,25 +383,61 @@ public sealed class ChatMessageManager
|
|||||||
|
|
||||||
private List<ChatLine> FormatMessage(MessageDto message)
|
private List<ChatLine> FormatMessage(MessageDto message)
|
||||||
{
|
{
|
||||||
var time = FormatDateTime(message.SentAt);
|
var time = FormatTime(message.SentAt);
|
||||||
var senderName = message.SenderUsername + ":";
|
// Show the display name, but keep color + click identity keyed to the username
|
||||||
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
// so they stay consistent with the user list and profile lookups.
|
||||||
|
var senderName = message.SenderDisplayName ?? message.SenderUsername;
|
||||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor)
|
||||||
var pad = new string(' ', 7);
|
?? NickColorHelper.GetAttribute(message.SenderUsername);
|
||||||
|
|
||||||
var lines = new List<ChatLine>();
|
var lines = new List<ChatLine>();
|
||||||
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
|
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
|
||||||
var attachments = message.Attachments ?? [];
|
var attachments = message.Attachments ?? [];
|
||||||
|
|
||||||
|
// Reply → dim quote line above the message; activating it jumps to the original
|
||||||
|
if (message.ReplyTo is { } replyTo)
|
||||||
|
lines.Add(ReplyQuoteLine(replyTo));
|
||||||
|
|
||||||
|
// /me action → "* nick waves" (CTCP ACTION content)
|
||||||
|
string? actionText = null;
|
||||||
|
var isAction = hasContent && MessageConventions.TryParseAction(message.Content, out actionText);
|
||||||
|
if (isAction)
|
||||||
|
{
|
||||||
|
var actionLines = EmojiHelper.ReplaceEmoji(actionText!).Split('\n');
|
||||||
|
var header = ActionHeaderSegments(time);
|
||||||
|
header.Add(new(senderName, senderColor));
|
||||||
|
header.Add(new(" ", null));
|
||||||
|
header.AddRange(ChatColors.SplitMentions(actionLines[0].TrimEnd('\r')));
|
||||||
|
lines.Add(new ChatLine(header));
|
||||||
|
|
||||||
|
for (int i = 1; i < actionLines.Length; i++)
|
||||||
|
{
|
||||||
|
var segments = RailPrefix();
|
||||||
|
segments.AddRange(ChatColors.SplitMentions(actionLines[i].TrimEnd('\r')));
|
||||||
|
lines.Add(new ChatLine(segments));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Header line: caption text, or a summary when the message is attachments-only
|
// Header line: caption text, or a summary when the message is attachments-only
|
||||||
if (hasContent)
|
if (isAction)
|
||||||
|
{
|
||||||
|
// already rendered above
|
||||||
|
}
|
||||||
|
else if (hasContent)
|
||||||
{
|
{
|
||||||
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||||
var contentLines = displayContent.Split('\n');
|
var contentLines = displayContent.Split('\n');
|
||||||
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {contentLines[0].TrimEnd('\r')}"));
|
|
||||||
|
var header = HeaderSegments(time, senderName, senderColor);
|
||||||
|
header.AddRange(ChatColors.SplitMentions(contentLines[0].TrimEnd('\r')));
|
||||||
|
lines.Add(new ChatLine(header));
|
||||||
|
|
||||||
for (int i = 1; i < contentLines.Length; i++)
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
lines.Add(new ChatLine(ChatColors.SplitMentions($"{indent}{contentLines[i].TrimEnd('\r')}")));
|
{
|
||||||
|
var segments = RailPrefix();
|
||||||
|
segments.AddRange(ChatColors.SplitMentions(contentLines[i].TrimEnd('\r')));
|
||||||
|
lines.Add(new ChatLine(segments));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -262,13 +447,15 @@ public sealed class ChatMessageManager
|
|||||||
1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]",
|
1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]",
|
||||||
_ => $"[{attachments.Count} attachments]",
|
_ => $"[{attachments.Count} attachments]",
|
||||||
};
|
};
|
||||||
lines.Add(BuildChatLine(time, senderName, senderColor, summary));
|
var header = HeaderSegments(time, senderName, senderColor);
|
||||||
|
header.Add(new(summary, null));
|
||||||
|
lines.Add(new ChatLine(header));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var l in lines)
|
foreach (var l in lines)
|
||||||
l.ContinuationIndent = indent.Length;
|
l.ContinuationPrefixSegments = RailPrefix();
|
||||||
|
|
||||||
// One block per attachment
|
// One block per attachment — every block hangs off the nick-column rail
|
||||||
foreach (var attachment in attachments)
|
foreach (var attachment in attachments)
|
||||||
{
|
{
|
||||||
switch (attachment.Kind)
|
switch (attachment.Kind)
|
||||||
@@ -279,24 +466,25 @@ public sealed class ChatMessageManager
|
|||||||
foreach (var artLine in attachment.AsciiPreview.Split('\n'))
|
foreach (var artLine in attachment.AsciiPreview.Split('\n'))
|
||||||
{
|
{
|
||||||
var trimmed = artLine.TrimEnd('\r');
|
var trimmed = artLine.TrimEnd('\r');
|
||||||
lines.Add(ChatLine.HasColorTags(trimmed)
|
var segments = RailPrefix();
|
||||||
? ChatLine.FromColoredText(pad + trimmed)
|
if (ChatLine.HasColorTags(trimmed))
|
||||||
: new ChatLine($"{pad}{trimmed}"));
|
segments.AddRange(ChatLine.FromColoredText(trimmed).Segments);
|
||||||
|
else
|
||||||
|
segments.Add(new(trimmed, null));
|
||||||
|
lines.Add(new ChatLine(segments));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.Add(AttachmentActionLine(pad,
|
lines.Add(ImageActionLine(attachment));
|
||||||
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
|
|
||||||
ChatColors.FileAttr, attachment));
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Core.Models.AttachmentKind.Audio:
|
case Core.Models.AttachmentKind.Audio:
|
||||||
lines.Add(AttachmentActionLine(pad,
|
lines.Add(AttachmentActionLine(
|
||||||
$"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
$"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||||
ChatColors.AudioAttr, attachment));
|
ChatColors.AudioAttr, attachment));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
lines.Add(AttachmentActionLine(pad,
|
lines.Add(AttachmentActionLine(
|
||||||
$"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
$"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||||
ChatColors.FileAttr, attachment));
|
ChatColors.FileAttr, attachment));
|
||||||
break;
|
break;
|
||||||
@@ -308,7 +496,7 @@ public sealed class ChatMessageManager
|
|||||||
{
|
{
|
||||||
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||||
foreach (var embed in message.Embeds)
|
foreach (var embed in message.Embeds)
|
||||||
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
lines.AddRange(FormatEmbed(embed, chatWidth));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
@@ -317,10 +505,14 @@ public sealed class ChatMessageManager
|
|||||||
line.SenderUsername = message.SenderUsername;
|
line.SenderUsername = message.SenderUsername;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasContent && !string.IsNullOrEmpty(_currentUser))
|
if (!string.IsNullOrEmpty(_currentUser))
|
||||||
{
|
{
|
||||||
|
// Being replied to counts as a mention, same as an explicit @nick
|
||||||
|
var isReplyToMe = message.ReplyTo is { } reply
|
||||||
|
&& reply.SenderUsername.Equals(_currentUser, StringComparison.OrdinalIgnoreCase);
|
||||||
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||||
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
if (isReplyToMe
|
||||||
|
|| ((hasContent || isAction) && Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase)))
|
||||||
{
|
{
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
line.IsMention = true;
|
line.IsMention = true;
|
||||||
@@ -330,75 +522,229 @@ public sealed class ChatMessageManager
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the action line below an image preview: "[open] [↓ save original] name [size]".
|
||||||
|
/// Each bracket is an <see cref="AttachmentActionSpan"/> so a mouse click can target it;
|
||||||
|
/// keyboard activation (Enter) uses the default action, open.
|
||||||
|
/// </summary>
|
||||||
|
private static ChatLine ImageActionLine(AttachmentDto attachment)
|
||||||
|
{
|
||||||
|
var segments = RailPrefix();
|
||||||
|
var col = segments.Sum(s => s.Text.GetColumns());
|
||||||
|
var spans = new List<AttachmentActionSpan>();
|
||||||
|
|
||||||
|
void AddAction(string text, AttachmentAction action)
|
||||||
|
{
|
||||||
|
var width = text.GetColumns();
|
||||||
|
spans.Add(new AttachmentActionSpan(col, col + width - 1, action));
|
||||||
|
segments.Add(new(text, ChatColors.FileAttr));
|
||||||
|
col += width;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddAction("[open]", AttachmentAction.OpenImage);
|
||||||
|
segments.Add(new(" ", null));
|
||||||
|
col += 1;
|
||||||
|
AddAction("[↓ save original]", AttachmentAction.SaveImage);
|
||||||
|
segments.Add(new($" {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", ChatColors.FileAttr));
|
||||||
|
|
||||||
|
return new ChatLine(segments)
|
||||||
|
{
|
||||||
|
AttachmentUrl = attachment.Url,
|
||||||
|
AttachmentFileName = attachment.FileName,
|
||||||
|
AttachmentKind = attachment.Kind,
|
||||||
|
ActionSpans = spans,
|
||||||
|
ContinuationPrefixSegments = RailPrefix(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
||||||
/// route activation (play audio, download file, save original image).
|
/// route activation (play audio, download file, save original image).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static ChatLine AttachmentActionLine(string pad, string text, Attribute color, AttachmentDto attachment)
|
private static ChatLine AttachmentActionLine(string text, Attribute color, AttachmentDto attachment)
|
||||||
{
|
{
|
||||||
var line = new ChatLine(new List<ChatSegment>
|
var segments = RailPrefix();
|
||||||
|
segments.Add(new(text, color));
|
||||||
|
return new ChatLine(segments)
|
||||||
{
|
{
|
||||||
new(pad, null),
|
AttachmentUrl = attachment.Url,
|
||||||
new(text, color),
|
AttachmentFileName = attachment.FileName,
|
||||||
});
|
AttachmentKind = attachment.Kind,
|
||||||
line.AttachmentUrl = attachment.Url;
|
ContinuationPrefixSegments = RailPrefix(),
|
||||||
line.AttachmentFileName = attachment.FileName;
|
|
||||||
line.AttachmentKind = attachment.Kind;
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
|
|
||||||
{
|
|
||||||
var segments = new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new(senderName, senderColor),
|
|
||||||
new(suffix, null)
|
|
||||||
};
|
};
|
||||||
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>Header variant for /me actions: "*" in the nick column, "* nick text" content.</summary>
|
||||||
|
private static List<ChatSegment> ActionHeaderSegments(string time) =>
|
||||||
|
[
|
||||||
|
new($"{time} ", ChatColors.TimestampAttr),
|
||||||
|
new(PadNick("*"), ChatColors.TimestampAttr),
|
||||||
|
new(" │ ", ChatColors.RailAttr),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dim "┌ nick: snippet" line above a reply. Carries the original message id so
|
||||||
|
/// activating it jumps there. Room-encrypted snippets arrive already decrypted (or as
|
||||||
|
/// the locked placeholder) — this only truncates for display.
|
||||||
|
/// </summary>
|
||||||
|
private static ChatLine ReplyQuoteLine(ReplyRefDto replyTo)
|
||||||
{
|
{
|
||||||
var segments = new List<ChatSegment>
|
const int maxSnippetCols = 60;
|
||||||
|
|
||||||
|
var snippet = replyTo.Content.Replace('\n', ' ').Replace('\r', ' ');
|
||||||
|
if (MessageConventions.TryParseAction(snippet, out var actionText))
|
||||||
|
snippet = $"* {replyTo.SenderUsername} {actionText}";
|
||||||
|
|
||||||
|
snippet = EmojiHelper.ReplaceEmoji(snippet);
|
||||||
|
if (snippet.GetColumns() > maxSnippetCols)
|
||||||
{
|
{
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
var sb = new StringBuilder();
|
||||||
new(senderName, senderColor),
|
int used = 0;
|
||||||
new(suffix, suffixColor)
|
foreach (var g in GraphemeHelper.GetGraphemes(snippet))
|
||||||
|
{
|
||||||
|
var gCols = Math.Max(g.GetColumns(), 1);
|
||||||
|
if (used + gCols > maxSnippetCols - 1) break;
|
||||||
|
sb.Append(g);
|
||||||
|
used += gCols;
|
||||||
|
}
|
||||||
|
snippet = sb.Append('…').ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var segments = RailPrefix();
|
||||||
|
segments.Add(new("┌ ", ChatColors.RailAttr));
|
||||||
|
segments.Add(new($"{replyTo.SenderUsername}: ", NickColorHelper.GetAttribute(replyTo.SenderUsername)));
|
||||||
|
segments.Add(new(snippet, ChatColors.SystemAttr));
|
||||||
|
|
||||||
|
return new ChatLine(segments)
|
||||||
|
{
|
||||||
|
JumpToMessageId = replyTo.MessageId,
|
||||||
|
ContinuationPrefixSegments = RailPrefix(),
|
||||||
};
|
};
|
||||||
return new ChatLine(segments);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
|
/// <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;
|
||||||
|
foreach (var g in GraphemeHelper.GetGraphemes(nick))
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string DateRuleLabel(DateTime date) => date.ToString("ddd, MMM d yyyy");
|
||||||
|
|
||||||
|
private static ChatLine DateRule(DateTime date)
|
||||||
|
{
|
||||||
|
var label = DateRuleLabel(date);
|
||||||
|
return new ChatLine([new($"── {label} ──", ChatColors.DateRuleAttr)])
|
||||||
|
{
|
||||||
|
RuleLabel = label,
|
||||||
|
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))
|
||||||
@@ -449,13 +795,11 @@ public sealed class ChatMessageManager
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatDateTime(DateTimeOffset timestamp)
|
// 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
|
||||||
if (timestamp.Date == DateTimeOffset.Now.Date)
|
// near midnight lands under the right date rule.
|
||||||
return timestamp.ToLocalTime().ToString("t");
|
private static string FormatTime(DateTimeOffset timestamp) =>
|
||||||
else
|
timestamp.ToLocalTime().ToString("HH:mm");
|
||||||
return timestamp.ToLocalTime().ToString("g");
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string FormatFileSize(long? bytes)
|
internal static string FormatFileSize(long? bytes)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ namespace EchoHub.Client.UI.Dialogs;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record ConnectDialogResult(
|
public record ConnectDialogResult(
|
||||||
string ServerUrl, string Username, string Password,
|
string ServerUrl, string Username, string Password,
|
||||||
bool IsRegister, bool RememberMe, string? SavedRefreshToken);
|
bool IsRegister, bool RememberMe, string? SavedRefreshToken,
|
||||||
|
string? DisplayName = null, string? InviteCode = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
||||||
@@ -29,7 +30,7 @@ public sealed class ConnectDialog
|
|||||||
savedServers ??= [];
|
savedServers ??= [];
|
||||||
|
|
||||||
var hasSavedServers = savedServers.Count > 0;
|
var hasSavedServers = savedServers.Count > 0;
|
||||||
var dialogHeight = hasSavedServers ? 22 : 18;
|
var dialogHeight = hasSavedServers ? 24 : 20;
|
||||||
|
|
||||||
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
|
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
|
||||||
|
|
||||||
@@ -153,26 +154,41 @@ public sealed class ConnectDialog
|
|||||||
Width = Dim.Fill(2)
|
Width = Dim.Fill(2)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only needed on servers with invite-gated registration; harmless elsewhere
|
||||||
|
var inviteLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Invite Code:",
|
||||||
|
X = 1,
|
||||||
|
Y = yOffset + 11
|
||||||
|
};
|
||||||
|
var inviteField = new TextField
|
||||||
|
{
|
||||||
|
Text = "",
|
||||||
|
X = 15,
|
||||||
|
Y = yOffset + 11,
|
||||||
|
Width = Dim.Fill(2)
|
||||||
|
};
|
||||||
|
|
||||||
var loginButton = new Button
|
var loginButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Login",
|
Text = "Login",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 20,
|
X = Pos.Center() - 20,
|
||||||
Y = yOffset + 11
|
Y = yOffset + 13
|
||||||
};
|
};
|
||||||
|
|
||||||
var registerButton = new Button
|
var registerButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Register",
|
Text = "Register",
|
||||||
X = Pos.Center() - 5,
|
X = Pos.Center() - 5,
|
||||||
Y = yOffset + 11
|
Y = yOffset + 13
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 10,
|
X = Pos.Center() + 10,
|
||||||
Y = yOffset + 11
|
Y = yOffset + 13
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wire saved server selection to auto-fill fields
|
// Wire saved server selection to auto-fill fields
|
||||||
@@ -262,7 +278,11 @@ public sealed class ConnectDialog
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
|
var displayName = displayField.Text?.Trim();
|
||||||
|
var inviteCode = inviteField.Text?.Trim();
|
||||||
|
result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null,
|
||||||
|
DisplayName: string.IsNullOrEmpty(displayName) ? null : displayName,
|
||||||
|
InviteCode: string.IsNullOrEmpty(inviteCode) ? null : inviteCode);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -276,6 +296,7 @@ public sealed class ConnectDialog
|
|||||||
|
|
||||||
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
|
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
|
||||||
tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
|
tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
|
||||||
|
inviteLabel, inviteField,
|
||||||
loginButton, registerButton, cancelButton);
|
loginButton, registerButton, cancelButton);
|
||||||
|
|
||||||
if (hasSavedServers && savedServerList is not null)
|
if (hasSavedServers && savedServerList is not null)
|
||||||
|
|||||||
@@ -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,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)];
|
||||||
|
}
|
||||||
@@ -17,6 +17,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> _protectedChannels = [];
|
||||||
|
private readonly HashSet<string> _mentionChannels = [];
|
||||||
|
private readonly HashSet<string> _privateChannels = [];
|
||||||
private string _activeChannel = string.Empty;
|
private string _activeChannel = string.Empty;
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
@@ -28,9 +30,11 @@ 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>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null,
|
||||||
|
IReadOnlySet<string>? privateChannels = null)
|
||||||
{
|
{
|
||||||
_channelNames.Clear();
|
_channelNames.Clear();
|
||||||
_channelNames.AddRange(channels);
|
_channelNames.AddRange(channels);
|
||||||
@@ -40,6 +44,12 @@ public class ChannelListSource : IListDataSource
|
|||||||
_protectedChannels.Clear();
|
_protectedChannels.Clear();
|
||||||
if (protectedChannels is not null)
|
if (protectedChannels is not null)
|
||||||
_protectedChannels.UnionWith(protectedChannels);
|
_protectedChannels.UnionWith(protectedChannels);
|
||||||
|
_mentionChannels.Clear();
|
||||||
|
if (mentionChannels is not null)
|
||||||
|
_mentionChannels.UnionWith(mentionChannels);
|
||||||
|
_privateChannels.Clear();
|
||||||
|
if (privateChannels is not null)
|
||||||
|
_privateChannels.UnionWith(privateChannels);
|
||||||
_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)
|
||||||
@@ -62,8 +72,12 @@ 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 ? "> " : " ";
|
||||||
// Trailing * marks password-protected (+k) channels
|
// Trailing * marks password-protected (+k) channels; ~ marks private (unlisted) ones
|
||||||
var channelText = _protectedChannels.Contains(name) ? $"#{name}*" : $"#{name}";
|
var channelText = $"#{name}";
|
||||||
|
if (_protectedChannels.Contains(name))
|
||||||
|
channelText += "*";
|
||||||
|
if (_privateChannels.Contains(name))
|
||||||
|
channelText += "~";
|
||||||
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
|
||||||
@@ -82,12 +96,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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;
|
||||||
|
using Terminal.Gui.Drivers;
|
||||||
using Terminal.Gui.Input;
|
using Terminal.Gui.Input;
|
||||||
using Terminal.Gui.Text;
|
using Terminal.Gui.Text;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
@@ -41,31 +42,33 @@ public sealed partial class MainWindow : Runnable
|
|||||||
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 const string DefaultInputTitle = "Message │ Enter=send │ Tab=complete │ Ctrl+K=search │ F6=pick message";
|
||||||
private static readonly Key F2Key = Key.F2;
|
private const KeyCode F2Key = KeyCode.F2;
|
||||||
private bool _hasStagedAttachments;
|
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) ?? "?";
|
||||||
|
|
||||||
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
|
// Key bindings as KeyCode constants: comparing raw KeyCodes avoids Key.Equals (which also
|
||||||
private static readonly Key EnterKey = Key.Enter;
|
// checks Handled), and constants make them usable as switch case labels.
|
||||||
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
private const KeyCode EnterKey = KeyCode.Enter;
|
||||||
private static readonly Key AltQKey = Key.Q.WithAlt;
|
private const KeyCode NewlineKey = KeyCode.N | KeyCode.CtrlMask;
|
||||||
private static readonly Key TabKey = Key.Tab;
|
private const KeyCode AltQKey = KeyCode.Q | KeyCode.AltMask;
|
||||||
private static readonly Key CtrlKKey = Key.K.WithCtrl;
|
private const KeyCode TabKey = KeyCode.Tab;
|
||||||
private static readonly Key CtrlVKey = Key.V.WithCtrl;
|
private const KeyCode CtrlKKey = KeyCode.K | KeyCode.CtrlMask;
|
||||||
private static readonly Key CtrlXKey = Key.X.WithCtrl;
|
private const KeyCode CtrlVKey = KeyCode.V | KeyCode.CtrlMask;
|
||||||
private static readonly Key CtrlCKey = Key.C.WithCtrl;
|
private const KeyCode CtrlXKey = KeyCode.X | KeyCode.CtrlMask;
|
||||||
private static readonly Key CtrlYKey = Key.Y.WithCtrl;
|
private const KeyCode CtrlCKey = KeyCode.C | KeyCode.CtrlMask;
|
||||||
private static readonly Key F6Key = Key.F6;
|
private const KeyCode CtrlYKey = KeyCode.Y | KeyCode.CtrlMask;
|
||||||
|
private const KeyCode F6Key = KeyCode.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", "/me", "/banner",
|
||||||
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/size", "/downloadpath",
|
"/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", "/invite", "/export", "/deleteaccount",
|
||||||
|
"/nuke", "/test-sound", "/quit", "/help"
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
@@ -87,6 +90,18 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string, string>? OnMessageSubmitted;
|
public event Action<string, string>? OnMessageSubmitted;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when local files arrive via paste or drag-and-drop to be staged as attachments.
|
||||||
|
/// Parameters: channel name, absolute paths of existing files.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, IReadOnlyList<string>>? OnFilesStaged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when raw image data is pasted from the clipboard (e.g. copied from a browser or a
|
||||||
|
/// screenshot tool). Parameters: channel name, PNG-encoded image bytes.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, byte[]>? OnImagePasted;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired when the user requests to connect via the menu.
|
/// Fired when the user requests to connect via the menu.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -162,11 +177,28 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string, string>? OnImageSaveRequested;
|
public event Action<string, string>? OnImageSaveRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user activates an image's "[open]" action to view it without saving.
|
||||||
|
/// Parameters: attachmentUrl, fileName.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, string>? OnImageOpenRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
|
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<Guid>? OnDeleteMessageRequested;
|
public event Action<Guid>? OnDeleteMessageRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user picks "Reply" on a message. Parameters: message id, sender username,
|
||||||
|
/// a short plain-text snippet for the reply strip.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<Guid, string, string>? OnReplyRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user cancels a pending reply (Esc in the input field).
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnReplyCancelRequested;
|
||||||
|
|
||||||
/// <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>
|
||||||
@@ -319,6 +351,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();
|
||||||
|
|
||||||
@@ -331,6 +369,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
KeyDown += OnWindowKeyDown;
|
KeyDown += OnWindowKeyDown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string? _stagedTitleFragment;
|
||||||
|
private string? _replyTitleFragment;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates the attachment staging indicator shown on the input frame's title, including the
|
/// 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.
|
/// current ASCII-art size for images. Passing an empty list restores the default hint.
|
||||||
@@ -340,15 +381,38 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_hasStagedAttachments = fileNames.Count > 0;
|
_hasStagedAttachments = fileNames.Count > 0;
|
||||||
if (fileNames.Count == 0)
|
if (fileNames.Count == 0)
|
||||||
{
|
{
|
||||||
_inputFrame.Title = DefaultInputTitle;
|
_stagedTitleFragment = null;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var names = string.Join(", ", fileNames);
|
var names = string.Join(", ", fileNames);
|
||||||
if (names.Length > 45)
|
if (names.Length > 45)
|
||||||
names = names[..42] + "...";
|
names = names[..42] + "...";
|
||||||
_inputFrame.Title = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear";
|
_stagedTitleFragment = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear";
|
||||||
}
|
}
|
||||||
|
UpdateInputTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows/clears the "replying to" strip on the input frame's title. Pass null to clear.
|
||||||
|
/// </summary>
|
||||||
|
public void SetReplyingTo(string? label)
|
||||||
|
{
|
||||||
|
_replyTitleFragment = label is null ? null : $"↩ Replying to {label} │ Esc=cancel";
|
||||||
|
UpdateInputTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasPendingReplyIndicator => _replyTitleFragment is not null;
|
||||||
|
|
||||||
|
private void UpdateInputTitle()
|
||||||
|
{
|
||||||
|
_inputFrame.Title = (_replyTitleFragment, _stagedTitleFragment) switch
|
||||||
|
{
|
||||||
|
(null, null) => DefaultInputTitle,
|
||||||
|
({ } reply, null) => reply,
|
||||||
|
(null, { } staged) => staged,
|
||||||
|
({ } reply, { } staged) => $"{reply} │ {staged}",
|
||||||
|
};
|
||||||
_inputFrame.SetNeedsDraw();
|
_inputFrame.SetNeedsDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +424,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)
|
||||||
{
|
{
|
||||||
@@ -370,6 +435,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +557,14 @@ public sealed partial class MainWindow : Runnable
|
|||||||
var line = source.GetLine(index.Value);
|
var line = source.GetLine(index.Value);
|
||||||
if (line is null) return;
|
if (line is null) return;
|
||||||
|
|
||||||
|
// A reply's quote line jumps to the original message (if it's in the buffer)
|
||||||
|
if (line.JumpToMessageId is { } jumpTarget)
|
||||||
|
{
|
||||||
|
ScrollToMessage(jumpTarget);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
{
|
{
|
||||||
@@ -505,7 +584,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
|
|
||||||
if (line.AttachmentKind == AttachmentKind.Image)
|
if (line.AttachmentKind == AttachmentKind.Image)
|
||||||
{
|
{
|
||||||
OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
// Keyboard/default activation opens the image for viewing;
|
||||||
|
// saving is the mouse span or the context menu.
|
||||||
|
OnImageOpenRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -544,7 +625,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
private void OnMessageListKeyDown(object? sender, Key e)
|
private void OnMessageListKeyDown(object? sender, Key e)
|
||||||
{
|
{
|
||||||
// F6 returns focus to the input box.
|
// F6 returns focus to the input box.
|
||||||
if (e.KeyCode == F6Key.KeyCode)
|
if (e.KeyCode == F6Key)
|
||||||
{
|
{
|
||||||
_inputField.SetFocus();
|
_inputField.SetFocus();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
@@ -573,7 +654,8 @@ public sealed partial class MainWindow : Runnable
|
|||||||
|
|
||||||
private void OnMessageListMouseEvent(object? sender, Mouse e)
|
private void OnMessageListMouseEvent(object? sender, Mouse e)
|
||||||
{
|
{
|
||||||
if (!e.Flags.HasFlag(MouseFlags.RightButtonClicked))
|
var leftClick = e.Flags.HasFlag(MouseFlags.LeftButtonClicked);
|
||||||
|
if (!leftClick && !e.Flags.HasFlag(MouseFlags.RightButtonClicked))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos)
|
if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos)
|
||||||
@@ -583,6 +665,30 @@ public sealed partial class MainWindow : Runnable
|
|||||||
if (index < 0 || index >= source.Count)
|
if (index < 0 || index >= source.Count)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// Left-click only activates the "[open]" / "[save original]" brackets on an
|
||||||
|
// attachment action line; anywhere else it falls through to normal selection.
|
||||||
|
if (leftClick)
|
||||||
|
{
|
||||||
|
var clicked = source.GetLine(index);
|
||||||
|
if (clicked?.ActionSpans is { } spans
|
||||||
|
&& clicked.AttachmentUrl is { } url && clicked.AttachmentFileName is { } name)
|
||||||
|
{
|
||||||
|
foreach (var span in spans)
|
||||||
|
{
|
||||||
|
if (pos.X < span.StartCol || pos.X > span.EndCol)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (span.Action == AttachmentAction.OpenImage)
|
||||||
|
OnImageOpenRequested?.Invoke(url, name);
|
||||||
|
else
|
||||||
|
OnImageSaveRequested?.Invoke(url, name);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Select the right-clicked row (so the menu acts on it and it highlights), then show the menu.
|
// Select the right-clicked row (so the menu acts on it and it highlights), then show the menu.
|
||||||
_messageList.SelectedItem = index;
|
_messageList.SelectedItem = index;
|
||||||
_messageList.SetFocus();
|
_messageList.SetFocus();
|
||||||
@@ -609,6 +715,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
switch (kind)
|
switch (kind)
|
||||||
{
|
{
|
||||||
case AttachmentKind.Image:
|
case AttachmentKind.Image:
|
||||||
|
items.Add(new MenuItem("Open image", "", () => OnImageOpenRequested?.Invoke(url, name), Key.Empty));
|
||||||
items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty));
|
items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty));
|
||||||
break;
|
break;
|
||||||
case AttachmentKind.Audio:
|
case AttachmentKind.Audio:
|
||||||
@@ -620,6 +727,20 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sender is not null && line.MessageId is { } replyTargetId)
|
||||||
|
{
|
||||||
|
items.Add(new MenuItem("Reply", "", () =>
|
||||||
|
{
|
||||||
|
// Strip the "HH:mm nick │ " header so the strip shows just the text
|
||||||
|
var snippet = line.ToString();
|
||||||
|
var railIdx = snippet.IndexOf(" │ ", StringComparison.Ordinal);
|
||||||
|
if (railIdx >= 0)
|
||||||
|
snippet = snippet[(railIdx + 3)..];
|
||||||
|
OnReplyRequested?.Invoke(replyTargetId, sender, snippet.Trim());
|
||||||
|
_inputField.SetFocus();
|
||||||
|
}, Key.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
if (sender is not null)
|
if (sender is not null)
|
||||||
{
|
{
|
||||||
items.Add(new MenuItem($"Mention @{sender}", "", () => MentionUser(sender), Key.Empty));
|
items.Add(new MenuItem($"Mention @{sender}", "", () => MentionUser(sender), Key.Empty));
|
||||||
@@ -661,6 +782,30 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scrolls the message list to a message's first line (used by reply quote lines).
|
||||||
|
/// No-op when the message isn't in the loaded buffer.
|
||||||
|
/// </summary>
|
||||||
|
private void ScrollToMessage(Guid messageId)
|
||||||
|
{
|
||||||
|
if (_messageList.Source is not ChatListSource source)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (int i = 0; i < source.Count; i++)
|
||||||
|
{
|
||||||
|
var line = source.GetLine(i);
|
||||||
|
// Match the message's own lines, not other replies' quote lines pointing at it
|
||||||
|
if (line?.MessageId == messageId && line.JumpToMessageId is null)
|
||||||
|
{
|
||||||
|
_messageList.SelectedItem = i;
|
||||||
|
_messageList.TopItem = Math.Max(0, i - 3);
|
||||||
|
_messageList.SetFocus();
|
||||||
|
_messageList.SetNeedsDraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ConfirmDeleteMessage(Guid messageId)
|
private void ConfirmDeleteMessage(Guid messageId)
|
||||||
{
|
{
|
||||||
var confirm = MessageBox.Query(_app, "Delete Message", "Delete this message?", "Delete", "Cancel");
|
var confirm = MessageBox.Query(_app, "Delete Message", "Delete this message?", "Delete", "Cancel");
|
||||||
@@ -690,18 +835,21 @@ public sealed partial class MainWindow : Runnable
|
|||||||
|
|
||||||
private void OnInputKeyDown(object? sender, Key e)
|
private void OnInputKeyDown(object? sender, Key e)
|
||||||
{
|
{
|
||||||
if (e.KeyCode == TabKey.KeyCode)
|
switch (e.KeyCode)
|
||||||
{
|
{
|
||||||
|
case TabKey:
|
||||||
TryAutocompleteCommand();
|
TryAutocompleteCommand();
|
||||||
e.Handled = true;
|
break;
|
||||||
}
|
|
||||||
else if (e.KeyCode == NewlineKey.KeyCode)
|
case KeyCode.Esc when HasPendingReplyIndicator:
|
||||||
{
|
OnReplyCancelRequested?.Invoke();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NewlineKey:
|
||||||
_inputField.InsertText("\n");
|
_inputField.InsertText("\n");
|
||||||
e.Handled = true;
|
break;
|
||||||
}
|
|
||||||
else if (e.KeyCode == EnterKey.KeyCode)
|
case EnterKey:
|
||||||
{
|
|
||||||
var text = _inputField.Text?.Trim() ?? string.Empty;
|
var text = _inputField.Text?.Trim() ?? string.Empty;
|
||||||
// Send when there's text, or when only attachments are staged (empty caption).
|
// Send when there's text, or when only attachments are staged (empty caption).
|
||||||
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
|
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
|
||||||
@@ -710,46 +858,50 @@ public sealed partial class MainWindow : Runnable
|
|||||||
OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
|
OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
|
||||||
_inputField.Text = string.Empty;
|
_inputField.Text = string.Empty;
|
||||||
}
|
}
|
||||||
e.Handled = true;
|
break;
|
||||||
}
|
|
||||||
else if (e.KeyCode == AltQKey.KeyCode)
|
case AltQKey:
|
||||||
{
|
|
||||||
_app.RequestStop();
|
_app.RequestStop();
|
||||||
e.Handled = true;
|
break;
|
||||||
}
|
|
||||||
else if (e.KeyCode == CtrlKKey.KeyCode)
|
case CtrlKKey:
|
||||||
{
|
|
||||||
ShowSearchDialog();
|
ShowSearchDialog();
|
||||||
e.Handled = true;
|
break;
|
||||||
}
|
|
||||||
else if (e.KeyCode == F6Key.KeyCode)
|
case F6Key:
|
||||||
{
|
|
||||||
// Move focus into the message list so you can select a message (arrows) and
|
// 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.)
|
// delete it (Delete). F6 again returns focus here. (Esc is the app quit key.)
|
||||||
FocusMessageList();
|
FocusMessageList();
|
||||||
e.Handled = true;
|
break;
|
||||||
}
|
|
||||||
else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode)
|
case CtrlVKey:
|
||||||
{
|
case CtrlYKey:
|
||||||
// If a file was copied in the OS file manager, the clipboard holds a file list
|
// Discord-style paste priority. Copied files in the OS file manager put a file
|
||||||
// (not text) — attach it. Otherwise paste text. This is the reliable path on
|
// list (not text) on the clipboard — attach them all. Copied image data (browser
|
||||||
// Windows Terminal, which never pastes copied files as text.
|
// right-click copy, screenshot tools) is attached as a PNG. Otherwise paste text.
|
||||||
|
// Terminals never deliver either of the first two as text, so this is the only path.
|
||||||
if (ClipboardFiles.TryGetFiles(out var pastedFiles))
|
if (ClipboardFiles.TryGetFiles(out var pastedFiles))
|
||||||
StageFiles(pastedFiles);
|
StageFiles(pastedFiles);
|
||||||
|
else if (!string.IsNullOrEmpty(_messageManager.CurrentChannel)
|
||||||
|
&& ClipboardImage.TryGetPng(out var pastedPng))
|
||||||
|
OnImagePasted?.Invoke(_messageManager.CurrentChannel, pastedPng);
|
||||||
else
|
else
|
||||||
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
||||||
e.Handled = true;
|
break;
|
||||||
}
|
|
||||||
else if (e.KeyCode == CtrlXKey.KeyCode)
|
case CtrlXKey:
|
||||||
{
|
|
||||||
GuardedClipboardAction(() => _inputField.Cut(), "cut");
|
GuardedClipboardAction(() => _inputField.Cut(), "cut");
|
||||||
e.Handled = true;
|
break;
|
||||||
}
|
|
||||||
else if (e.KeyCode == CtrlCKey.KeyCode)
|
case CtrlCKey:
|
||||||
{
|
|
||||||
GuardedClipboardAction(() => _inputField.Copy(), "copy");
|
GuardedClipboardAction(() => _inputField.Copy(), "copy");
|
||||||
e.Handled = true;
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return; // not one of ours — leave e.Handled false so the key types normally
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -859,17 +1011,16 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Routes files (from a drop or a file-clipboard paste) through the /send pipeline, which
|
/// Stages files (from a drop or a file-clipboard paste) as attachments in one batch; the
|
||||||
/// stages them; the next Enter sends them with any typed caption.
|
/// next Enter sends them with any typed caption.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void StageFiles(IEnumerable<string> files)
|
private void StageFiles(IReadOnlyList<string> files)
|
||||||
{
|
{
|
||||||
var channel = _messageManager.CurrentChannel;
|
var channel = _messageManager.CurrentChannel;
|
||||||
if (string.IsNullOrEmpty(channel))
|
if (string.IsNullOrEmpty(channel))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
foreach (var file in files)
|
OnFilesStaged?.Invoke(channel, files);
|
||||||
OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\"");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnChatViewportChanged()
|
private void OnChatViewportChanged()
|
||||||
@@ -885,21 +1036,25 @@ public sealed partial class MainWindow : Runnable
|
|||||||
|
|
||||||
private void OnWindowKeyDown(object? sender, Key e)
|
private void OnWindowKeyDown(object? sender, Key e)
|
||||||
{
|
{
|
||||||
if (e.KeyCode == AltQKey.KeyCode)
|
switch (e.KeyCode)
|
||||||
{
|
{
|
||||||
|
case AltQKey:
|
||||||
_app.RequestStop();
|
_app.RequestStop();
|
||||||
e.Handled = true;
|
break;
|
||||||
}
|
|
||||||
else if (e.KeyCode == F2Key.KeyCode)
|
case F2Key:
|
||||||
{
|
|
||||||
ToggleUsersPanel();
|
ToggleUsersPanel();
|
||||||
e.Handled = true;
|
break;
|
||||||
}
|
|
||||||
else if (e.KeyCode == CtrlKKey.KeyCode)
|
case CtrlKKey:
|
||||||
{
|
|
||||||
ShowSearchDialog();
|
ShowSearchDialog();
|
||||||
e.Handled = true;
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowSearchDialog()
|
private void ShowSearchDialog()
|
||||||
@@ -913,6 +1068,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)
|
||||||
@@ -1016,6 +1174,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,6 +1182,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)
|
||||||
{
|
{
|
||||||
@@ -1054,13 +1245,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
|
||||||
@@ -1079,6 +1272,28 @@ public sealed partial class MainWindow : Runnable
|
|||||||
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)
|
||||||
@@ -1180,9 +1395,6 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void RefreshMessages()
|
private void RefreshMessages()
|
||||||
{
|
|
||||||
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
|
||||||
if (messages is not null)
|
|
||||||
{
|
{
|
||||||
var width = _messageList.Viewport.Width;
|
var width = _messageList.Viewport.Width;
|
||||||
|
|
||||||
@@ -1193,13 +1405,21 @@ public sealed partial class MainWindow : Runnable
|
|||||||
else
|
else
|
||||||
width = _lastChatWidth;
|
width = _lastChatWidth;
|
||||||
|
|
||||||
|
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
||||||
|
if (messages is not null)
|
||||||
|
{
|
||||||
var source = new ChatListSource();
|
var source = new ChatListSource();
|
||||||
|
|
||||||
if (width > 0)
|
if (width > 0)
|
||||||
{
|
{
|
||||||
foreach (var line in messages)
|
foreach (var line in messages)
|
||||||
|
{
|
||||||
|
if (line.RuleLabel is not null)
|
||||||
|
source.Add(ExpandRule(line, width));
|
||||||
|
else
|
||||||
source.AddRange(line.Wrap(width, line.ContinuationIndent));
|
source.AddRange(line.Wrap(width, line.ContinuationIndent));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
source.AddRange(messages);
|
source.AddRange(messages);
|
||||||
@@ -1211,16 +1431,39 @@ 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, _channelProtected);
|
var privateChannels = _channelNames
|
||||||
|
.Where(n => _channelPublic.TryGetValue(n, out var isPublic) && !isPublic)
|
||||||
|
.ToHashSet();
|
||||||
|
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
|
||||||
|
_channelProtected, _messageManager.MentionChannels, privateChannels);
|
||||||
_channelList.Source = _channelListSource;
|
_channelList.Source = _channelListSource;
|
||||||
|
|
||||||
// Restore selection to current channel
|
// Restore selection to current channel
|
||||||
@@ -1296,8 +1539,15 @@ 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);
|
// Users connected only via the IRC gateway get a tag — they lack client features
|
||||||
return (text, nameColor, u.Username);
|
// (encryption, attachments, profiles), which is useful context in conversation.
|
||||||
|
if (u.IsIrc)
|
||||||
|
text += " [irc]";
|
||||||
|
// Fall back to the deterministic per-nick palette so user-list colors
|
||||||
|
// 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);
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ public static class HubConstants
|
|||||||
{
|
{
|
||||||
public const string ChatHubPath = "/hubs/chat";
|
public const string ChatHubPath = "/hubs/chat";
|
||||||
public const string DefaultChannel = "general";
|
public const string DefaultChannel = "general";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connection-id prefix for IRC gateway connections. The presence tracker uses it to
|
||||||
|
/// tell IRC-only users apart from native (SignalR) clients.
|
||||||
|
/// </summary>
|
||||||
|
public const string IrcConnectionIdPrefix = "irc-";
|
||||||
public const int DefaultHistoryCount = 100;
|
public const int DefaultHistoryCount = 100;
|
||||||
public const int MaxMessageLength = 2000;
|
public const int MaxMessageLength = 2000;
|
||||||
public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB
|
public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace EchoHub.Core.Constants;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-protocol message conventions. Action messages (<c>/me</c>) use the IRC CTCP ACTION
|
||||||
|
/// wire format 0x01 + "ACTION " + text + 0x01 as the stored content, so IRC clients
|
||||||
|
/// interoperate natively (irssi's /me arrives in exactly this shape) and the TUI renders
|
||||||
|
/// "* nick text". In end-to-end encrypted rooms the marker encrypts along with the text.
|
||||||
|
/// </summary>
|
||||||
|
public static class MessageConventions
|
||||||
|
{
|
||||||
|
public const string ActionPrefix = "\u0001ACTION ";
|
||||||
|
public const string ActionSuffix = "\u0001";
|
||||||
|
|
||||||
|
public static string FormatAction(string text) => $"{ActionPrefix}{text}{ActionSuffix}";
|
||||||
|
|
||||||
|
public static bool TryParseAction(string content, [NotNullWhen(true)] out string? actionText)
|
||||||
|
{
|
||||||
|
if (content.Length > ActionPrefix.Length + ActionSuffix.Length
|
||||||
|
&& content.StartsWith(ActionPrefix, StringComparison.Ordinal)
|
||||||
|
&& content.EndsWith(ActionSuffix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
actionText = content[ActionPrefix.Length..^ActionSuffix.Length];
|
||||||
|
return actionText.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionText = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public interface IChannelService
|
|||||||
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<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName);
|
||||||
Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName);
|
Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ namespace EchoHub.Core.Contracts;
|
|||||||
|
|
||||||
public interface IChatBroadcaster
|
public interface IChatBroadcaster
|
||||||
{
|
{
|
||||||
Task SendMessageToChannelAsync(string channelName, MessageDto message);
|
/// <summary>
|
||||||
|
/// Broadcast a chat message to a channel. <paramref name="excludeConnectionId"/> is the
|
||||||
|
/// connection the message originated from (IRC convention: never echo a message back to
|
||||||
|
/// the connection that sent it — its client already displayed it locally). Other
|
||||||
|
/// connections of the same user (e.g. an IRC session alongside a TUI session) still
|
||||||
|
/// receive the message.
|
||||||
|
/// </summary>
|
||||||
|
Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null);
|
||||||
Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null);
|
Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null);
|
||||||
Task SendUserLeftAsync(string channelName, string username);
|
Task SendUserLeftAsync(string channelName, string username);
|
||||||
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||||
@@ -12,6 +19,7 @@ public interface IChatBroadcaster
|
|||||||
Task SendUserKickedAsync(string channelName, string username, string? reason);
|
Task SendUserKickedAsync(string channelName, string username, string? reason);
|
||||||
Task SendUserBannedAsync(string username, string? reason);
|
Task SendUserBannedAsync(string username, string? reason);
|
||||||
Task SendMessageDeletedAsync(string channelName, Guid messageId);
|
Task SendMessageDeletedAsync(string channelName, Guid messageId);
|
||||||
|
Task SendChannelDeletedAsync(string channelName);
|
||||||
Task SendChannelNukedAsync(string channelName);
|
Task SendChannelNukedAsync(string channelName);
|
||||||
Task SendErrorAsync(string connectionId, string message);
|
Task SendErrorAsync(string connectionId, string message);
|
||||||
Task ForceDisconnectUserAsync(List<string> connectionIds, string reason);
|
Task ForceDisconnectUserAsync(List<string> connectionIds, string reason);
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ public interface IChatService
|
|||||||
Task<(List<MessageDto> History, string? Error, bool PasswordRequired)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName, string? password = null);
|
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. originConnectionId identifies the connection the message came from so
|
||||||
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
|
// broadcasters can avoid echoing it back to that one connection (IRC convention);
|
||||||
|
// the sender's other sessions still receive it. replyToMessageId references the message
|
||||||
|
// being replied to; it must exist in the same channel.
|
||||||
|
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null);
|
||||||
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0);
|
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0);
|
||||||
|
|
||||||
// Presence
|
// Presence
|
||||||
@@ -24,6 +27,7 @@ public interface IChatService
|
|||||||
// Broadcasting (used by controllers and IRC gateway)
|
// Broadcasting (used by controllers and IRC gateway)
|
||||||
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
||||||
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||||
|
Task BroadcastChannelDeletedAsync(string channelName);
|
||||||
|
|
||||||
// Query operations (used by IRC gateway for WHOIS)
|
// Query operations (used by IRC gateway for WHOIS)
|
||||||
Task<List<string>> GetChannelsForUserAsync(string username);
|
Task<List<string>> GetChannelsForUserAsync(string username);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public interface IEchoHubClient
|
|||||||
Task UserKicked(string channelName, string username, string? reason);
|
Task UserKicked(string channelName, string username, string? reason);
|
||||||
Task UserBanned(string username, string? reason);
|
Task UserBanned(string username, string? reason);
|
||||||
Task MessageDeleted(string channelName, Guid messageId);
|
Task MessageDeleted(string channelName, Guid messageId);
|
||||||
|
Task ChannelDeleted(string channelName);
|
||||||
Task ChannelNuked(string channelName);
|
Task ChannelNuked(string channelName);
|
||||||
Task ForceDisconnect(string reason);
|
Task ForceDisconnect(string reason);
|
||||||
Task Error(string message);
|
Task Error(string message);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ namespace EchoHub.Core.Contracts;
|
|||||||
|
|
||||||
public interface IUserService
|
public interface IUserService
|
||||||
{
|
{
|
||||||
Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null);
|
// inviteCode is required when the server runs with Server:Registration = "invite";
|
||||||
|
// "closed" refuses all new accounts. Both REST and the IRC gateway funnel through here.
|
||||||
|
Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null, string? inviteCode = null);
|
||||||
Task<UserOperationResult> AuthenticateUserAsync(string username, string password);
|
Task<UserOperationResult> AuthenticateUserAsync(string username, string password);
|
||||||
Task<UserProfileDto?> GetUserProfileAsync(string username);
|
Task<UserProfileDto?> GetUserProfileAsync(string username);
|
||||||
Task<UserProfileDto?> GetUserByIdAsync(Guid userId);
|
Task<UserProfileDto?> GetUserByIdAsync(Guid userId);
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
/// <summary>Password re-confirmation for destructive self-service account actions.</summary>
|
||||||
|
public record DeleteAccountRequest(string Password);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everything the server holds about a user, as stored. For end-to-end encrypted rooms the
|
||||||
|
/// message content in here is room ciphertext — the server cannot include plaintext it never had.
|
||||||
|
/// </summary>
|
||||||
|
public record UserDataExportDto(
|
||||||
|
DateTimeOffset ExportedAt,
|
||||||
|
string ServerName,
|
||||||
|
UserProfileDto Profile,
|
||||||
|
List<ExportedMessageDto> Messages,
|
||||||
|
List<ExportedAttachmentDto> Attachments);
|
||||||
|
|
||||||
|
public record ExportedMessageDto(
|
||||||
|
Guid Id,
|
||||||
|
string ChannelName,
|
||||||
|
DateTimeOffset SentAt,
|
||||||
|
string Content,
|
||||||
|
Guid? ReplyToMessageId);
|
||||||
|
|
||||||
|
public record ExportedAttachmentDto(
|
||||||
|
string FileName,
|
||||||
|
string Url,
|
||||||
|
long FileSize,
|
||||||
|
string Kind,
|
||||||
|
string ChannelName,
|
||||||
|
DateTimeOffset SentAt);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace EchoHub.Core.DTOs;
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
public record RegisterRequest(string Username, string Password, string? DisplayName = null);
|
public record RegisterRequest(string Username, string Password, string? DisplayName = null, string? InviteCode = null);
|
||||||
|
|
||||||
public record LoginRequest(string Username, string Password);
|
public record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,20 @@ public record MessageDto(
|
|||||||
string ChannelName,
|
string ChannelName,
|
||||||
DateTimeOffset SentAt,
|
DateTimeOffset SentAt,
|
||||||
List<AttachmentDto>? Attachments = null,
|
List<AttachmentDto>? Attachments = null,
|
||||||
List<EmbedDto>? Embeds = null);
|
List<EmbedDto>? Embeds = null,
|
||||||
|
string? SenderDisplayName = null,
|
||||||
|
ReplyRefDto? ReplyTo = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reference to the message a reply targets. <see cref="Content"/> is treated exactly like
|
||||||
|
/// message content on the wire: transport-encrypted, and for end-to-end encrypted rooms it is
|
||||||
|
/// room ciphertext the client must decrypt (the server truncates only plaintext snippets).
|
||||||
|
/// Null on a <see cref="MessageDto"/> when the original message no longer exists.
|
||||||
|
/// </summary>
|
||||||
|
public record ReplyRefDto(
|
||||||
|
Guid MessageId,
|
||||||
|
string SenderUsername,
|
||||||
|
string Content);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A file attached to a message. <see cref="AsciiPreview"/> holds the color-tag art for
|
/// A file attached to a message. <see cref="AsciiPreview"/> holds the color-tag art for
|
||||||
@@ -58,6 +71,23 @@ public record CreateChannelRequest(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record ChannelCryptoDto(bool IsEncrypted, string? EncryptionSalt);
|
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>
|
/// <summary>
|
||||||
/// Passphrase change for an encrypted channel: the client proves knowledge of the old
|
/// 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.
|
/// passphrase (old auth key), then supplies the re-wrapped room key under the new one.
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
public record CreateInviteRequest(int? MaxUses = null, int? ExpiresInHours = null);
|
||||||
|
|
||||||
|
public record InviteDto(
|
||||||
|
string Code,
|
||||||
|
string CreatedByUsername,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset? ExpiresAt,
|
||||||
|
int MaxUses,
|
||||||
|
int UseCount);
|
||||||
@@ -30,6 +30,7 @@ public record UserPresenceDto(
|
|||||||
string? NicknameColor,
|
string? NicknameColor,
|
||||||
UserStatus Status,
|
UserStatus Status,
|
||||||
string? StatusMessage,
|
string? StatusMessage,
|
||||||
ServerRole Role);
|
ServerRole Role,
|
||||||
|
bool IsIrc = false);
|
||||||
|
|
||||||
public record AvatarUploadResponse(string AvatarAscii);
|
public record AvatarUploadResponse(string AvatarAscii);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
namespace EchoHub.Core.DTOs;
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels);
|
public record ServerStatusDto(
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
int OnlineUsers,
|
||||||
|
int TotalChannels,
|
||||||
|
string RegistrationMode = "open");
|
||||||
|
|
||||||
public record EncryptionKeyResponse(string Key);
|
public record EncryptionKeyResponse(string Key);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A registration invite code. When the server runs with <c>Server:Registration = "invite"</c>,
|
||||||
|
/// new accounts (REST and IRC alike) require a valid, unexpired, not-fully-used code.
|
||||||
|
/// </summary>
|
||||||
|
public class InviteCode
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public required string Code { get; set; }
|
||||||
|
public Guid CreatedByUserId { get; set; }
|
||||||
|
public string CreatedByUsername { get; set; } = string.Empty;
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Null means the code never expires.</summary>
|
||||||
|
public DateTimeOffset? ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
public int MaxUses { get; set; } = 1;
|
||||||
|
public int UseCount { get; set; }
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ 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>Message this one replies to, if any. The target may since have been deleted.</summary>
|
||||||
|
public Guid? ReplyToMessageId { get; set; }
|
||||||
|
|
||||||
/// <summary>Files attached to this message. Empty for a plain text message.</summary>
|
/// <summary>Files attached to this message. Empty for a plain text message.</summary>
|
||||||
public List<Attachment> Attachments { get; set; } = [];
|
public List<Attachment> Attachments { get; set; } = [];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EchoHub.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders short text as a 5-row block-character banner (the <c>/banner</c> command).
|
||||||
|
/// Entirely local — a hand-rolled figlet-style font, no dependencies, no network.
|
||||||
|
/// Output is plain message content, so it travels (and encrypts) like any other text.
|
||||||
|
/// </summary>
|
||||||
|
public static class AsciiBannerService
|
||||||
|
{
|
||||||
|
public const int MaxInputLength = 20;
|
||||||
|
private const int Rows = 5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders <paramref name="text"/> as banner lines joined with '\n'.
|
||||||
|
/// Returns null when the input is empty or contains no renderable characters.
|
||||||
|
/// Characters outside the font (letters, digits, and basic punctuation) are skipped.
|
||||||
|
/// </summary>
|
||||||
|
public static string? Render(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
text = text.Trim();
|
||||||
|
if (text.Length > MaxInputLength)
|
||||||
|
text = text[..MaxInputLength];
|
||||||
|
|
||||||
|
var glyphs = new List<string[]>();
|
||||||
|
foreach (var ch in text.ToUpperInvariant())
|
||||||
|
{
|
||||||
|
if (Font.TryGetValue(ch, out var glyph))
|
||||||
|
glyphs.Add(glyph);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (glyphs.Count == 0 || glyphs.All(g => g == Font[' ']))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var lines = new StringBuilder();
|
||||||
|
for (var row = 0; row < Rows; row++)
|
||||||
|
{
|
||||||
|
if (row > 0)
|
||||||
|
lines.Append('\n');
|
||||||
|
for (var i = 0; i < glyphs.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0)
|
||||||
|
lines.Append(' ');
|
||||||
|
lines.Append(glyphs[i][row].Replace('#', '█').Replace('.', ' '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim trailing spaces per line — cheaper payload, identical rendering
|
||||||
|
return string.Join('\n', lines.ToString().Split('\n').Select(l => l.TrimEnd()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glyphs are authored with '#' (ink) and '.' (blank); all rows of a glyph share one width.
|
||||||
|
private static readonly Dictionary<char, string[]> Font = new()
|
||||||
|
{
|
||||||
|
[' '] = ["..", "..", "..", "..", ".."],
|
||||||
|
['A'] = [".##.", "#..#", "####", "#..#", "#..#"],
|
||||||
|
['B'] = ["###.", "#..#", "###.", "#..#", "###."],
|
||||||
|
['C'] = [".###", "#...", "#...", "#...", ".###"],
|
||||||
|
['D'] = ["###.", "#..#", "#..#", "#..#", "###."],
|
||||||
|
['E'] = ["####", "#...", "###.", "#...", "####"],
|
||||||
|
['F'] = ["####", "#...", "###.", "#...", "#..."],
|
||||||
|
['G'] = [".###", "#...", "#.##", "#..#", ".###"],
|
||||||
|
['H'] = ["#..#", "#..#", "####", "#..#", "#..#"],
|
||||||
|
['I'] = ["###", ".#.", ".#.", ".#.", "###"],
|
||||||
|
['J'] = ["..##", "...#", "...#", "#..#", ".##."],
|
||||||
|
['K'] = ["#..#", "#.#.", "##..", "#.#.", "#..#"],
|
||||||
|
['L'] = ["#...", "#...", "#...", "#...", "####"],
|
||||||
|
['M'] = ["#...#", "##.##", "#.#.#", "#...#", "#...#"],
|
||||||
|
['N'] = ["#...#", "##..#", "#.#.#", "#..##", "#...#"],
|
||||||
|
['O'] = [".##.", "#..#", "#..#", "#..#", ".##."],
|
||||||
|
['P'] = ["###.", "#..#", "###.", "#...", "#..."],
|
||||||
|
['Q'] = [".##.", "#..#", "#..#", "#.##", ".###"],
|
||||||
|
['R'] = ["###.", "#..#", "###.", "#.#.", "#..#"],
|
||||||
|
['S'] = [".###", "#...", ".##.", "...#", "###."],
|
||||||
|
['T'] = ["###", ".#.", ".#.", ".#.", ".#."],
|
||||||
|
['U'] = ["#..#", "#..#", "#..#", "#..#", ".##."],
|
||||||
|
['V'] = ["#...#", "#...#", "#...#", ".#.#.", "..#.."],
|
||||||
|
['W'] = ["#...#", "#...#", "#.#.#", "##.##", "#...#"],
|
||||||
|
['X'] = ["#...#", ".#.#.", "..#..", ".#.#.", "#...#"],
|
||||||
|
['Y'] = ["#...#", ".#.#.", "..#..", "..#..", "..#.."],
|
||||||
|
['Z'] = ["####", "...#", "..#.", ".#..", "####"],
|
||||||
|
['0'] = [".##.", "#.##", "##.#", "#..#", ".##."],
|
||||||
|
['1'] = [".#.", "##.", ".#.", ".#.", "###"],
|
||||||
|
['2'] = [".##.", "#..#", "..#.", ".#..", "####"],
|
||||||
|
['3'] = ["###.", "...#", ".##.", "...#", "###."],
|
||||||
|
['4'] = ["#..#", "#..#", "####", "...#", "...#"],
|
||||||
|
['5'] = ["####", "#...", "###.", "...#", "###."],
|
||||||
|
['6'] = [".##.", "#...", "###.", "#..#", ".##."],
|
||||||
|
['7'] = ["####", "...#", "..#.", ".#..", ".#.."],
|
||||||
|
['8'] = [".##.", "#..#", ".##.", "#..#", ".##."],
|
||||||
|
['9'] = [".##.", "#..#", ".###", "...#", ".##."],
|
||||||
|
['!'] = ["#", "#", "#", ".", "#"],
|
||||||
|
['?'] = [".##.", "#..#", "..#.", "....", "..#."],
|
||||||
|
['.'] = [".", ".", ".", ".", "#"],
|
||||||
|
[','] = ["..", "..", "..", ".#", "#."],
|
||||||
|
['-'] = ["....", "....", "####", "....", "...."],
|
||||||
|
['\''] = ["#", "#", ".", ".", "."],
|
||||||
|
[':'] = [".", "#", ".", "#", "."],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,16 +14,24 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
public async Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null)
|
||||||
{
|
{
|
||||||
// Decrypt content for IRC clients (they can't handle app-layer encryption)
|
// Decrypt transport-encrypted content 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.
|
||||||
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage);
|
var decryptedMessage = message with
|
||||||
|
{
|
||||||
|
Content = _encryption.Decrypt(message.Content),
|
||||||
|
ReplyTo = message.ReplyTo is { } reply ? reply with { Content = _encryption.Decrypt(reply.Content) } : null,
|
||||||
|
};
|
||||||
|
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl);
|
||||||
|
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
// IRC convention: don't echo sender's own message
|
// IRC convention: don't echo a message back to the connection that sent it
|
||||||
if (conn.Nickname == message.SenderUsername)
|
// (its client already displayed it locally). Match by connection id, not
|
||||||
|
// nickname — the same account may also be online via the TUI or a second
|
||||||
|
// IRC client, and those sessions must still receive the message.
|
||||||
|
if (conn.ConnectionId == excludeConnectionId)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
@@ -93,6 +101,14 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendChannelDeletedAsync(string channelName)
|
||||||
|
{
|
||||||
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
{
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :Channel #{channelName} has been deleted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SendChannelNukedAsync(string channelName)
|
public async Task SendChannelNukedAsync(string channelName)
|
||||||
{
|
{
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
|
|
||||||
namespace EchoHub.Server.Irc;
|
namespace EchoHub.Server.Irc;
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ public sealed class IrcClientConnection : IAsyncDisposable
|
|||||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
|
||||||
// Connection identity
|
// Connection identity
|
||||||
public string ConnectionId { get; } = $"irc-{Guid.NewGuid()}";
|
public string ConnectionId { get; } = $"{HubConstants.IrcConnectionIdPrefix}{Guid.NewGuid()}";
|
||||||
|
|
||||||
// Registration state
|
// Registration state
|
||||||
public string? Nickname { get; set; }
|
public string? Nickname { get; set; }
|
||||||
|
|||||||
@@ -432,8 +432,12 @@ public sealed class IrcCommandHandler
|
|||||||
// Replay history (decrypt — history is encrypted for SignalR transport)
|
// Replay history (decrypt — history is encrypted for SignalR transport)
|
||||||
foreach (var m in history)
|
foreach (var m in history)
|
||||||
{
|
{
|
||||||
var decrypted = m with { Content = _encryption.Decrypt(m.Content) };
|
var decrypted = m with
|
||||||
var lines = IrcMessageFormatter.FormatMessage(decrypted);
|
{
|
||||||
|
Content = _encryption.Decrypt(m.Content),
|
||||||
|
ReplyTo = m.ReplyTo is { } reply ? reply with { Content = _encryption.Decrypt(reply.Content) } : null,
|
||||||
|
};
|
||||||
|
var lines = IrcMessageFormatter.FormatMessage(decrypted, _options.PublicBaseUrl);
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
await _conn.SendAsync(line);
|
await _conn.SendAsync(line);
|
||||||
}
|
}
|
||||||
@@ -486,7 +490,7 @@ public sealed class IrcCommandHandler
|
|||||||
if (channelName is null) return;
|
if (channelName is null) return;
|
||||||
|
|
||||||
var error = await _chatService.SendMessageAsync(
|
var error = await _chatService.SendMessageAsync(
|
||||||
_conn.UserId!.Value, _conn.Nickname!, channelName, content);
|
_conn.UserId!.Value, _conn.Nickname!, channelName, content, _conn.ConnectionId);
|
||||||
|
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,58 +1,69 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using EchoHub.Core.Constants;
|
||||||
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;
|
||||||
|
|
||||||
public static partial class IrcMessageFormatter
|
public static class IrcMessageFormatter
|
||||||
{
|
{
|
||||||
private const int MaxIrcLineContentBytes = 400;
|
private const int MaxIrcLineContentBytes = 400;
|
||||||
|
private const int MaxReplySnippetLength = 80;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a MessageDto as one or more IRC PRIVMSG lines.
|
/// Format a MessageDto as one or more IRC PRIVMSG lines.
|
||||||
|
/// Attachments are rendered as single link lines \u2014 the widely-supported IRC convention
|
||||||
|
/// (clients auto-preview or open plain http(s) URLs) \u2014 never as terminal color art.
|
||||||
|
/// <paramref name="publicBaseUrl"/> makes the links absolute so any IRC client can open them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<string> FormatMessage(MessageDto message)
|
public static List<string> FormatMessage(MessageDto message, string? publicBaseUrl = null)
|
||||||
{
|
{
|
||||||
var lines = new List<string>();
|
var lines = new List<string>();
|
||||||
var ircChannel = $"#{message.ChannelName}";
|
var ircChannel = $"#{message.ChannelName}";
|
||||||
var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub";
|
var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub";
|
||||||
|
|
||||||
|
// Reply reference → the "> nick: snippet" quoting convention IRC users know
|
||||||
|
var replyPrefix = FormatReplyPrefix(message.ReplyTo);
|
||||||
|
|
||||||
// Caption text first (may be empty when the message is attachments-only)
|
// Caption text first (may be empty when the message is attachments-only)
|
||||||
if (!string.IsNullOrEmpty(message.Content))
|
if (!string.IsNullOrEmpty(message.Content))
|
||||||
{
|
{
|
||||||
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
// /me actions arrive as CTCP ACTION content; each chunk must stay a
|
||||||
|
// well-formed CTCP message (\x01ACTION …\x01) or clients render garbage.
|
||||||
|
if (MessageConventions.TryParseAction(message.Content, out var actionText))
|
||||||
|
{
|
||||||
|
if (replyPrefix is not null)
|
||||||
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{replyPrefix.TrimEnd(' ', '|', ' ')}");
|
||||||
|
|
||||||
|
foreach (var chunk in SplitMessage(actionText, MaxIrcLineContentBytes))
|
||||||
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{MessageConventions.FormatAction(chunk)}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var content = replyPrefix is not null ? replyPrefix + message.Content : message.Content;
|
||||||
|
foreach (var chunk in SplitMessage(content, MaxIrcLineContentBytes))
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else if (replyPrefix is not null)
|
||||||
|
{
|
||||||
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{replyPrefix.TrimEnd(' ', '|', ' ')}");
|
||||||
|
}
|
||||||
|
|
||||||
// One block per attachment
|
// One link line per attachment
|
||||||
if (message.Attachments is { Count: > 0 })
|
if (message.Attachments is { Count: > 0 })
|
||||||
{
|
{
|
||||||
foreach (var attachment in message.Attachments)
|
foreach (var attachment in message.Attachments)
|
||||||
{
|
{
|
||||||
switch (attachment.Kind)
|
var url = ToAbsoluteUrl(attachment.Url, publicBaseUrl);
|
||||||
|
var tag = attachment.Kind switch
|
||||||
{
|
{
|
||||||
case AttachmentKind.Image:
|
AttachmentKind.Image => $"[Image: {attachment.FileName}]",
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}");
|
AttachmentKind.Audio => $"\u266a [Audio: {attachment.FileName}]",
|
||||||
if (attachment.AsciiPreview is not null)
|
_ => $"[File: {attachment.FileName}]",
|
||||||
{
|
};
|
||||||
foreach (var line in attachment.AsciiPreview.Split('\n'))
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{tag} {url}");
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +77,39 @@ public static partial class IrcMessageFormatter
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "> nick: snippet | " prefix for replies. Room ciphertext can't be rendered
|
||||||
|
/// (IRC can't join encrypted rooms anyway) and is shown as a placeholder.
|
||||||
|
/// </summary>
|
||||||
|
private static string? FormatReplyPrefix(ReplyRefDto? replyTo)
|
||||||
|
{
|
||||||
|
if (replyTo is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var snippet = RoomCrypto.IsRoomCiphertext(replyTo.Content)
|
||||||
|
? "[encrypted]"
|
||||||
|
: replyTo.Content.Replace('\n', ' ').Replace('\r', ' ');
|
||||||
|
|
||||||
|
if (MessageConventions.TryParseAction(snippet, out var actionText))
|
||||||
|
snippet = $"* {replyTo.SenderUsername} {actionText}";
|
||||||
|
|
||||||
|
if (snippet.Length > MaxReplySnippetLength)
|
||||||
|
snippet = snippet[..MaxReplySnippetLength] + "…";
|
||||||
|
|
||||||
|
return $"> {replyTo.SenderUsername}: {snippet} | ";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Joins a relative attachment path onto the configured public base URL.
|
||||||
|
/// Already-absolute URLs and unset base URLs pass through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToAbsoluteUrl(string url, string? publicBaseUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(publicBaseUrl) || Uri.IsWellFormedUriString(url, UriKind.Absolute))
|
||||||
|
return url;
|
||||||
|
return $"{publicBaseUrl.TrimEnd('/')}/{url.TrimStart('/')}";
|
||||||
|
}
|
||||||
|
|
||||||
/// <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>
|
||||||
@@ -93,35 +137,6 @@ public static partial class IrcMessageFormatter
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients.
|
|
||||||
/// Also passes through content that already uses ANSI codes unchanged.
|
|
||||||
/// </summary>
|
|
||||||
public static string ColorTagsToAnsi(string text)
|
|
||||||
{
|
|
||||||
if (!text.Contains('{'))
|
|
||||||
return text;
|
|
||||||
|
|
||||||
return ColorTagRegex().Replace(text, match =>
|
|
||||||
{
|
|
||||||
if (match.Groups[1].Success) // {X} reset
|
|
||||||
return "\x1b[0m";
|
|
||||||
if (match.Groups[2].Success) // {F:RRGGBB} or {B:RRGGBB}
|
|
||||||
{
|
|
||||||
var hex = match.Groups[3].Value;
|
|
||||||
var r = Convert.ToInt32(hex[..2], 16);
|
|
||||||
var g = Convert.ToInt32(hex[2..4], 16);
|
|
||||||
var b = Convert.ToInt32(hex[4..6], 16);
|
|
||||||
var code = match.Groups[2].Value == "F" ? "38" : "48";
|
|
||||||
return $"\x1b[{code};2;{r};{g};{b}m";
|
|
||||||
}
|
|
||||||
return match.Value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
|
|
||||||
private static partial Regex ColorTagRegex();
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
|
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -12,4 +12,11 @@ public sealed class IrcOptions
|
|||||||
public string? TlsCertPassword { get; set; }
|
public string? TlsCertPassword { get; set; }
|
||||||
public string ServerName { get; set; } = "echohub";
|
public string ServerName { get; set; } = "echohub";
|
||||||
public string? Motd { get; set; }
|
public string? Motd { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public HTTP(S) base of this EchoHub server (e.g. "https://chat.example.com"),
|
||||||
|
/// used to turn relative attachment URLs into absolute links IRC clients can open.
|
||||||
|
/// When unset, attachment lines fall back to the relative path.
|
||||||
|
/// </summary>
|
||||||
|
public string? PublicBaseUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace EchoHub.Server.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anti-spam thresholds, bound from the "Spam" config section. Defaults are lenient enough
|
||||||
|
/// that a fast typist never trips them; Mods and above are always exempt.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpamOptions
|
||||||
|
{
|
||||||
|
/// <summary>Master switch for all spam protection.</summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Max messages a user may send per <see cref="WindowSeconds"/> window.</summary>
|
||||||
|
public int MaxMessagesPerWindow { get; set; } = 8;
|
||||||
|
|
||||||
|
public int WindowSeconds { get; set; } = 5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many identical messages in a row are tolerated; the next one is rejected.
|
||||||
|
/// (End-to-end encrypted rooms are naturally exempt — identical plaintext produces
|
||||||
|
/// different ciphertext per message, so repeats never look identical to the server.)
|
||||||
|
/// </summary>
|
||||||
|
public int MaxDuplicateMessages { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-mute duration once a user accumulates <see cref="ViolationThreshold"/> rejected
|
||||||
|
/// sends within <see cref="ViolationWindowMinutes"/>. 0 disables auto-mute (rejections
|
||||||
|
/// still apply). The mute uses the normal timed-mute machinery, so moderators see it
|
||||||
|
/// and <c>MuteExpirationService</c> lifts it.
|
||||||
|
/// </summary>
|
||||||
|
public int AutoMuteMinutes { get; set; } = 5;
|
||||||
|
|
||||||
|
public int ViolationThreshold { get; set; } = 5;
|
||||||
|
|
||||||
|
public int ViolationWindowMinutes { get; set; } = 5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Max *first-time* channel joins per <see cref="JoinWindowSeconds"/> window. Joins of
|
||||||
|
/// channels the user already belongs to (reconnect/auto-join) never count, but a brand-new
|
||||||
|
/// user's first connect joins every public channel at once — keep this above your public
|
||||||
|
/// channel count.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxJoinsPerWindow { get; set; } = 25;
|
||||||
|
|
||||||
|
public int JoinWindowSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>Max channel creations per <see cref="ChannelCreateWindowMinutes"/> window.</summary>
|
||||||
|
public int MaxChannelCreatesPerWindow { get; set; } = 3;
|
||||||
|
|
||||||
|
public int ChannelCreateWindowMinutes { get; set; } = 10;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ public class AuthController : ControllerBase
|
|||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
||||||
{
|
{
|
||||||
var result = await _userService.RegisterUserAsync(request.Username, request.Password, request.DisplayName);
|
var result = await _userService.RegisterUserAsync(request.Username, request.Password, request.DisplayName, request.InviteCode);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
return MapUserError(result);
|
return MapUserError(result);
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ using EchoHub.Core.Security;
|
|||||||
using EchoHub.Core.Services;
|
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;
|
||||||
|
|
||||||
@@ -26,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,
|
||||||
@@ -34,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;
|
||||||
@@ -43,6 +47,7 @@ public class ChannelsController : ControllerBase
|
|||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
|
_uploadLimits = uploadLimits;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -93,6 +98,21 @@ public class ChannelsController : ControllerBase
|
|||||||
return Ok(crypto);
|
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>
|
/// <summary>
|
||||||
/// Changes an encrypted channel's passphrase by re-wrapping its room key.
|
/// 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;
|
/// The caller proves knowledge of the old passphrase via the old auth key;
|
||||||
@@ -142,6 +162,8 @@ public class ChannelsController : ControllerBase
|
|||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
return MapChannelError(result);
|
return MapChannelError(result);
|
||||||
|
|
||||||
|
await _chatService.BroadcastChannelDeletedAsync(channel.ToLowerInvariant().Trim());
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,12 +174,18 @@ public class ChannelsController : ControllerBase
|
|||||||
/// uploads ciphertext blobs and declares each file's kind (<c>kind</c>) and pre-rendered,
|
/// 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.
|
/// room-encrypted preview (<c>preview</c>), aligned by file order — the server never inspects them.
|
||||||
/// </summary>
|
/// </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")]
|
[HttpPost("{channel}/messages")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
[RequestSizeLimit((long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
|
|
||||||
[RequestFormLimits(MultipartBodyLengthLimit = (long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
|
|
||||||
public async Task<IActionResult> SendMessageWithAttachments(string channel, [FromQuery] string? size = null)
|
public async Task<IActionResult> SendMessageWithAttachments(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)
|
||||||
@@ -179,8 +207,8 @@ public class ChannelsController : ControllerBase
|
|||||||
var files = Request.Form.Files;
|
var files = Request.Form.Files;
|
||||||
if (files.Count == 0)
|
if (files.Count == 0)
|
||||||
return BadRequest(new ErrorResponse("At least one attachment is required. Send plain text over the chat connection."));
|
return BadRequest(new ErrorResponse("At least one attachment is required. Send plain text over the chat connection."));
|
||||||
if (files.Count > HubConstants.MaxAttachmentsPerMessage)
|
if (files.Count > _uploadLimits.MaxAttachmentsPerMessage)
|
||||||
return BadRequest(new ErrorResponse($"A message may carry at most {HubConstants.MaxAttachmentsPerMessage} attachments."));
|
return BadRequest(new ErrorResponse($"A message may carry at most {_uploadLimits.MaxAttachmentsPerMessage} attachments."));
|
||||||
|
|
||||||
var sender = await _db.Users.FindAsync(userId);
|
var sender = await _db.Users.FindAsync(userId);
|
||||||
if (sender is not null && sender.IsMuted && (sender.MutedUntil is null || sender.MutedUntil > DateTimeOffset.UtcNow))
|
if (sender is not null && sender.IsMuted && (sender.MutedUntil is null || sender.MutedUntil > DateTimeOffset.UtcNow))
|
||||||
@@ -215,7 +243,7 @@ public class ChannelsController : ControllerBase
|
|||||||
if (string.IsNullOrEmpty(previewPlain))
|
if (string.IsNullOrEmpty(previewPlain))
|
||||||
previewPlain = null;
|
previewPlain = null;
|
||||||
|
|
||||||
if (file.Length > MaxForKind(kind))
|
if (file.Length > _uploadLimits.MaxForKind(kind))
|
||||||
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size."));
|
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size."));
|
||||||
|
|
||||||
using var encryptedStream = file.OpenReadStream();
|
using var encryptedStream = file.OpenReadStream();
|
||||||
@@ -228,8 +256,8 @@ public class ChannelsController : ControllerBase
|
|||||||
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
||||||
kind = isImage ? AttachmentKind.Image : isAudio ? AttachmentKind.Audio : AttachmentKind.File;
|
kind = isImage ? AttachmentKind.Image : isAudio ? AttachmentKind.Audio : AttachmentKind.File;
|
||||||
|
|
||||||
if (file.Length > MaxForKind(kind))
|
if (file.Length > _uploadLimits.MaxForKind(kind))
|
||||||
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {MaxForKind(kind) / (1024 * 1024)} MB."));
|
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {_uploadLimits.MaxForKind(kind) / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
string filePath;
|
string filePath;
|
||||||
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
||||||
@@ -282,7 +310,8 @@ public class ChannelsController : ControllerBase
|
|||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
message.SentAt,
|
message.SentAt,
|
||||||
attachmentDtos);
|
attachmentDtos,
|
||||||
|
SenderDisplayName: sender?.DisplayName);
|
||||||
|
|
||||||
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||||
|
|
||||||
@@ -296,13 +325,6 @@ public class ChannelsController : ControllerBase
|
|||||||
_ => AttachmentKind.File,
|
_ => AttachmentKind.File,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static long MaxForKind(AttachmentKind kind) => kind switch
|
|
||||||
{
|
|
||||||
AttachmentKind.Image => HubConstants.MaxImageSizeBytes,
|
|
||||||
AttachmentKind.Audio => HubConstants.MaxAudioFileSizeBytes,
|
|
||||||
_ => HubConstants.MaxFileSizeBytes,
|
|
||||||
};
|
|
||||||
|
|
||||||
[HttpPost("{channel}/send-url")]
|
[HttpPost("{channel}/send-url")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
|
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
|
||||||
@@ -343,13 +365,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('.'))
|
||||||
@@ -425,7 +447,8 @@ public class ChannelsController : ControllerBase
|
|||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
message.SentAt,
|
message.SentAt,
|
||||||
[new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))]);
|
[new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))],
|
||||||
|
SenderDisplayName: sender?.DisplayName);
|
||||||
|
|
||||||
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ public class FilesController : ControllerBase
|
|||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves an uploaded file. Anonymous by design: the unguessable GUID in the URL is the
|
||||||
|
/// access token (Discord-CDN-style capability URL), so attachment links can be opened
|
||||||
|
/// directly in a browser and shared to IRC clients. E2E-encrypted room blobs are
|
||||||
|
/// ciphertext at rest, so anonymous access reveals nothing for those channels.
|
||||||
|
/// </summary>
|
||||||
[HttpGet("{fileId}")]
|
[HttpGet("{fileId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public IActionResult GetFile(string fileId)
|
public IActionResult GetFile(string fileId)
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(fileId, out _))
|
if (!Guid.TryParse(fileId, out _))
|
||||||
@@ -48,6 +55,11 @@ public class FilesController : ControllerBase
|
|||||||
_ => "application/octet-stream"
|
_ => "application/octet-stream"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Images and audio render inline so a browser displays them instead of
|
||||||
|
// downloading; everything else keeps the attachment disposition.
|
||||||
|
if (contentType.StartsWith("image/") || contentType.StartsWith("audio/"))
|
||||||
|
return PhysicalFile(filePath, contentType);
|
||||||
|
|
||||||
var fileName = Path.GetFileName(filePath);
|
var fileName = Path.GetFileName(filePath);
|
||||||
return PhysicalFile(filePath, contentType, fileName);
|
return PhysicalFile(filePath, contentType, fileName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invite-code management for invite-gated registration (Admin+ only).
|
||||||
|
/// Codes live in this server's own database — there is no central service.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/invites")]
|
||||||
|
[Authorize]
|
||||||
|
[EnableRateLimiting("general")]
|
||||||
|
public class InvitesController : ControllerBase
|
||||||
|
{
|
||||||
|
private const int MaxActiveInvites = 200;
|
||||||
|
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly ILogger<InvitesController> _logger;
|
||||||
|
|
||||||
|
public InvitesController(EchoHubDbContext db, ILogger<InvitesController> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateInviteRequest request)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var maxUses = request.MaxUses ?? 1;
|
||||||
|
if (maxUses is < 1 or > 1000)
|
||||||
|
return BadRequest(new ErrorResponse("MaxUses must be between 1 and 1000."));
|
||||||
|
|
||||||
|
if (request.ExpiresInHours is < 1 or > 24 * 365)
|
||||||
|
return BadRequest(new ErrorResponse("ExpiresInHours must be between 1 and 8760."));
|
||||||
|
|
||||||
|
if (await _db.InviteCodes.CountAsync(i => i.UseCount < i.MaxUses) >= MaxActiveInvites)
|
||||||
|
return BadRequest(new ErrorResponse($"Too many active invites (max {MaxActiveInvites}). Revoke unused ones first."));
|
||||||
|
|
||||||
|
var invite = new InviteCode
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = GenerateCode(),
|
||||||
|
CreatedByUserId = caller!.Id,
|
||||||
|
CreatedByUsername = caller.Username,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
ExpiresAt = request.ExpiresInHours is { } hours ? DateTimeOffset.UtcNow.AddHours(hours) : null,
|
||||||
|
MaxUses = maxUses,
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.InviteCodes.Add(invite);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Invite code created by {User} (uses: {MaxUses}, expires: {Expires})",
|
||||||
|
caller.Username, invite.MaxUses, invite.ExpiresAt?.ToString("u") ?? "never");
|
||||||
|
|
||||||
|
return Ok(ToDto(invite));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List()
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var invites = await _db.InviteCodes
|
||||||
|
.OrderByDescending(i => i.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(invites.Select(ToDto).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{code}")]
|
||||||
|
public async Task<IActionResult> Revoke(string code)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var normalized = code.Trim().ToUpperInvariant();
|
||||||
|
var invite = await _db.InviteCodes.FirstOrDefaultAsync(i => i.Code == normalized);
|
||||||
|
if (invite is null)
|
||||||
|
return NotFound(new ErrorResponse("Invite code not found."));
|
||||||
|
|
||||||
|
_db.InviteCodes.Remove(invite);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Invite code revoked by {User}", caller!.Username);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Unguessable, unambiguous code like "K7QM-3XPF" (no 0/O/1/I).</summary>
|
||||||
|
private static string GenerateCode()
|
||||||
|
{
|
||||||
|
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
Span<char> chars = stackalloc char[8];
|
||||||
|
for (var i = 0; i < chars.Length; i++)
|
||||||
|
chars[i] = alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)];
|
||||||
|
return $"{new string(chars[..4])}-{new string(chars[4..])}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InviteDto ToDto(InviteCode i) =>
|
||||||
|
new(i.Code, i.CreatedByUsername, i.CreatedAt, i.ExpiresAt, i.MaxUses, i.UseCount);
|
||||||
|
|
||||||
|
private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole)
|
||||||
|
{
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return (null, Unauthorized(new ErrorResponse("Authentication required.")));
|
||||||
|
|
||||||
|
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
|
||||||
|
if (caller is null)
|
||||||
|
return (null, Unauthorized(new ErrorResponse("User not found.")));
|
||||||
|
|
||||||
|
if (caller.Role < minimumRole)
|
||||||
|
return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher.")));
|
||||||
|
|
||||||
|
return (caller, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,11 +31,19 @@ public class ServerController : ControllerBase
|
|||||||
var userCount = await _db.Users.CountAsync();
|
var userCount = await _db.Users.CountAsync();
|
||||||
var channelCount = await _db.Channels.CountAsync();
|
var channelCount = await _db.Channels.CountAsync();
|
||||||
|
|
||||||
|
var registrationMode = (_config["Server:Registration"] ?? "open").Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"invite" => "invite",
|
||||||
|
"closed" => "closed",
|
||||||
|
_ => "open",
|
||||||
|
};
|
||||||
|
|
||||||
var status = new ServerStatusDto(
|
var status = new ServerStatusDto(
|
||||||
_config["Server:Name"] ?? "EchoHub Server",
|
_config["Server:Name"] ?? "EchoHub Server",
|
||||||
_config["Server:Description"],
|
_config["Server:Description"],
|
||||||
userCount,
|
userCount,
|
||||||
channelCount);
|
channelCount,
|
||||||
|
registrationMode);
|
||||||
|
|
||||||
return Ok(status);
|
return Ok(status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
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.Models;
|
||||||
using EchoHub.Core.Services;
|
using EchoHub.Core.Services;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace EchoHub.Server.Controllers;
|
namespace EchoHub.Server.Controllers;
|
||||||
|
|
||||||
@@ -16,13 +20,45 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[EnableRateLimiting("general")]
|
[EnableRateLimiting("general")]
|
||||||
public class UsersController : ControllerBase
|
public class UsersController : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Username tombstoned messages are re-attributed to after account deletion.
|
||||||
|
/// Reserved — <see cref="UserService"/> refuses to register it.
|
||||||
|
/// </summary>
|
||||||
|
public const string DeletedUserName = "deleted-user";
|
||||||
|
|
||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
private readonly ImageToAsciiService _asciiService;
|
private readonly ImageToAsciiService _asciiService;
|
||||||
|
private readonly UploadLimits _uploadLimits;
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly IMessageEncryptionService _encryption;
|
||||||
|
private readonly FileStorageService _fileStorage;
|
||||||
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private readonly ILogger<UsersController> _logger;
|
||||||
|
|
||||||
public UsersController(IUserService userService, ImageToAsciiService asciiService)
|
public UsersController(
|
||||||
|
IUserService userService,
|
||||||
|
ImageToAsciiService asciiService,
|
||||||
|
UploadLimits uploadLimits,
|
||||||
|
EchoHubDbContext db,
|
||||||
|
IMessageEncryptionService encryption,
|
||||||
|
FileStorageService fileStorage,
|
||||||
|
PresenceTracker presenceTracker,
|
||||||
|
IEnumerable<IChatBroadcaster> broadcasters,
|
||||||
|
IConfiguration config,
|
||||||
|
ILogger<UsersController> logger)
|
||||||
{
|
{
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
_asciiService = asciiService;
|
_asciiService = asciiService;
|
||||||
|
_uploadLimits = uploadLimits;
|
||||||
|
_db = db;
|
||||||
|
_encryption = encryption;
|
||||||
|
_fileStorage = fileStorage;
|
||||||
|
_presenceTracker = presenceTracker;
|
||||||
|
_broadcasters = broadcasters;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{username}/profile")]
|
[HttpGet("{username}/profile")]
|
||||||
@@ -65,8 +101,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();
|
||||||
|
|
||||||
@@ -82,6 +118,139 @@ public class UsersController : ControllerBase
|
|||||||
return Ok(new AvatarUploadResponse(asciiArt));
|
return Ok(new AvatarUploadResponse(asciiArt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everything the server stores about the caller, as stored: profile, their messages
|
||||||
|
/// (end-to-end encrypted room content stays ciphertext — the server never had plaintext),
|
||||||
|
/// and metadata of their uploaded attachments. "You own the data" made demonstrable.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("me/export")]
|
||||||
|
public async Task<IActionResult> ExportMyData()
|
||||||
|
{
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
|
var userId = Guid.Parse(userIdClaim);
|
||||||
|
var profile = await _userService.GetUserByIdAsync(userId);
|
||||||
|
if (profile is null)
|
||||||
|
return Unauthorized(new ErrorResponse("User not found."));
|
||||||
|
|
||||||
|
var messages = await _db.Messages
|
||||||
|
.Where(m => m.SenderUserId == userId)
|
||||||
|
.OrderBy(m => m.SentAt)
|
||||||
|
.Join(_db.Channels, m => m.ChannelId, c => c.Id, (m, c) => new { m, ChannelName = c.Name })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var messageIds = messages.Select(x => x.m.Id).ToList();
|
||||||
|
var attachments = await _db.Attachments
|
||||||
|
.Where(a => messageIds.Contains(a.MessageId))
|
||||||
|
.ToListAsync();
|
||||||
|
var messageById = messages.ToDictionary(x => x.m.Id, x => x);
|
||||||
|
|
||||||
|
var export = new UserDataExportDto(
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
_config["Server:Name"] ?? "EchoHub Server",
|
||||||
|
profile,
|
||||||
|
messages.Select(x => new ExportedMessageDto(
|
||||||
|
x.m.Id,
|
||||||
|
x.ChannelName,
|
||||||
|
x.m.SentAt,
|
||||||
|
// Strip only the server's at-rest layer; room ciphertext passes through as-is
|
||||||
|
_encryption.Decrypt(x.m.Content),
|
||||||
|
x.m.ReplyToMessageId)).ToList(),
|
||||||
|
attachments.Select(a =>
|
||||||
|
{
|
||||||
|
var owner = messageById[a.MessageId];
|
||||||
|
return new ExportedAttachmentDto(
|
||||||
|
a.FileName, a.Url, a.FileSize, a.Kind.ToString(),
|
||||||
|
owner.ChannelName, owner.m.SentAt);
|
||||||
|
}).ToList());
|
||||||
|
|
||||||
|
_logger.LogInformation("{User} exported their data ({Messages} messages, {Attachments} attachments)",
|
||||||
|
profile.Username, export.Messages.Count, export.Attachments.Count);
|
||||||
|
|
||||||
|
return Ok(export);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Self-service account deletion (password re-confirmed). Removes the account, its refresh
|
||||||
|
/// tokens and memberships (FK cascade), and every attachment blob the user uploaded.
|
||||||
|
/// Their messages are kept but tombstoned to <see cref="DeletedUserName"/> — deleting them
|
||||||
|
/// outright would silently gut other people's conversations.
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("me")]
|
||||||
|
public async Task<IActionResult> DeleteMyAccount([FromBody] DeleteAccountRequest request)
|
||||||
|
{
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
|
var user = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
|
||||||
|
if (user is null)
|
||||||
|
return Unauthorized(new ErrorResponse("User not found."));
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(request.Password) || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||||
|
return Unauthorized(new ErrorResponse("Password is incorrect."));
|
||||||
|
|
||||||
|
if (user.Role == ServerRole.Owner
|
||||||
|
&& !await _db.Users.AnyAsync(u => u.Role == ServerRole.Owner && u.Id != user.Id))
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse(
|
||||||
|
"You are the only Owner of this server. Promote another Owner (or shut the server down) before deleting this account."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var username = user.Username;
|
||||||
|
|
||||||
|
// Their uploaded blobs: attachments hanging off their messages
|
||||||
|
var attachmentInfo = await _db.Messages
|
||||||
|
.Where(m => m.SenderUserId == user.Id)
|
||||||
|
.SelectMany(m => m.Attachments)
|
||||||
|
.Select(a => new { a.Id, a.Url })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var fileId in attachmentInfo
|
||||||
|
.Select(a => a.Url.Split('/').LastOrDefault())
|
||||||
|
.Where(id => !string.IsNullOrEmpty(id)))
|
||||||
|
{
|
||||||
|
try { _fileStorage.DeleteFile(fileId!); }
|
||||||
|
catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete blob {FileId} during account deletion", fileId); }
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachmentIds = attachmentInfo.Select(a => a.Id).ToList();
|
||||||
|
await _db.Attachments.Where(a => attachmentIds.Contains(a.Id)).ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
// Tombstone their messages, then remove the account (cascades tokens + memberships)
|
||||||
|
await _db.Messages
|
||||||
|
.Where(m => m.SenderUserId == user.Id)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(m => m.SenderUserId, Guid.Empty)
|
||||||
|
.SetProperty(m => m.SenderUsername, DeletedUserName));
|
||||||
|
|
||||||
|
_db.Users.Remove(user);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Kick their live sessions and clear them from user lists everywhere
|
||||||
|
var (connectionIds, channels) = _presenceTracker.ForceRemoveUser(username);
|
||||||
|
foreach (var channel in channels)
|
||||||
|
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channel, username));
|
||||||
|
if (connectionIds.Count > 0)
|
||||||
|
await BroadcastToAllAsync(b => b.ForceDisconnectUserAsync(connectionIds, "Account deleted."));
|
||||||
|
|
||||||
|
_logger.LogInformation("Account '{User}' self-deleted ({Attachments} attachment blobs removed)",
|
||||||
|
username, attachmentIds.Count);
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
||||||
|
{
|
||||||
|
foreach (var broadcaster in _broadcasters)
|
||||||
|
{
|
||||||
|
try { await action(broadcaster); }
|
||||||
|
catch { /* logged by broadcaster */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private IActionResult MapUserError(UserOperationResult result) => result.Error switch
|
private IActionResult MapUserError(UserOperationResult result) => result.Error switch
|
||||||
{
|
{
|
||||||
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
|
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class EchoHubDbContext : DbContext
|
|||||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
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>();
|
||||||
|
public DbSet<InviteCode> InviteCodes => Set<InviteCode>();
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -98,6 +99,14 @@ public class EchoHubDbContext : DbContext
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<InviteCode>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(i => i.Id);
|
||||||
|
entity.HasIndex(i => i.Code).IsUnique();
|
||||||
|
entity.Property(i => i.Code).IsRequired().HasMaxLength(32);
|
||||||
|
entity.Property(i => i.CreatedByUsername).IsRequired().HasMaxLength(50);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<RefreshToken>(entity =>
|
modelBuilder.Entity<RefreshToken>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(r => r.Id);
|
entity.HasKey(r => r.Id);
|
||||||
|
|||||||
+373
@@ -0,0 +1,373 @@
|
|||||||
|
// <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("20260717165218_AddInvitesAndReplies")]
|
||||||
|
partial class AddInvitesAndReplies
|
||||||
|
{
|
||||||
|
/// <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.InviteCode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedByUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MaxUses")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("UseCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("InviteCodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
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?>("ReplyToMessageId")
|
||||||
|
.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,56 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInvitesAndReplies : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ReplyToMessageId",
|
||||||
|
table: "Messages",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "InviteCodes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Code = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||||
|
CreatedByUserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
CreatedByUsername = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||||
|
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
ExpiresAt = table.Column<long>(type: "INTEGER", nullable: true),
|
||||||
|
MaxUses = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
UseCount = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_InviteCodes", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InviteCodes_Code",
|
||||||
|
table: "InviteCodes",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "InviteCodes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ReplyToMessageId",
|
||||||
|
table: "Messages");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,6 +117,45 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.ToTable("ChannelMemberships");
|
b.ToTable("ChannelMemberships");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.InviteCode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedByUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MaxUses")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("UseCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("InviteCodes");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -146,6 +185,9 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
.HasMaxLength(32000)
|
.HasMaxLength(32000)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ReplyToMessageId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<Guid>("SenderUserId")
|
b.Property<Guid>("SenderUserId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ public class ChatHub : Hub<IEchoHubClient>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendMessage(string channelName, string content)
|
public async Task SendMessage(string channelName, string content, Guid? replyToMessageId = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content);
|
var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content, Context.ConnectionId, replyToMessageId);
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
await Clients.Caller.Error(error);
|
await Clients.Caller.Error(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using EchoHub.Core.Contracts;
|
|||||||
using EchoHub.Core.Services;
|
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;
|
||||||
@@ -102,6 +103,19 @@ 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);
|
||||||
|
|
||||||
|
// ── Spam protection (admin-configurable via the "Spam" section) ──────
|
||||||
|
var spamOptions = builder.Configuration.GetSection("Spam").Get<SpamOptions>() ?? new SpamOptions();
|
||||||
|
builder.Services.AddSingleton(spamOptions);
|
||||||
|
builder.Services.AddSingleton<SpamGuard>();
|
||||||
|
// 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>();
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ public class ChannelService : IChannelService
|
|||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly PresenceTracker _presenceTracker;
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly SpamGuard _spamGuard;
|
||||||
private readonly ILogger<ChannelService> _logger;
|
private readonly ILogger<ChannelService> _logger;
|
||||||
|
|
||||||
public ChannelService(
|
public ChannelService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
|
SpamGuard spamGuard,
|
||||||
ILogger<ChannelService> logger)
|
ILogger<ChannelService> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
|
_spamGuard = spamGuard;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +77,16 @@ public class ChannelService : IChannelService
|
|||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
// Channel-creation throttle (spam guard; Mods and above are exempt)
|
||||||
|
if (_spamGuard.Enabled)
|
||||||
|
{
|
||||||
|
var creator = await db.Users.FindAsync(creatorUserId);
|
||||||
|
var verdict = _spamGuard.CheckChannelCreate(creatorUserId, creator?.Role ?? ServerRole.Member);
|
||||||
|
if (verdict.Kind != SpamVerdictKind.Allowed)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||||
|
verdict.Reason ?? "You're creating channels too fast.");
|
||||||
|
}
|
||||||
|
|
||||||
if (await db.Channels.AnyAsync(c => c.Name == channelName))
|
if (await db.Channels.AnyAsync(c => c.Name == channelName))
|
||||||
return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists.");
|
return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists.");
|
||||||
|
|
||||||
@@ -288,6 +301,42 @@ public class ChannelService : IChannelService
|
|||||||
c.PasswordHash != null, c.WrappedRoomKey != null);
|
c.PasswordHash != null, c.WrappedRoomKey != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using EchoHub.Core.Constants;
|
|||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Core.Security;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -19,6 +20,7 @@ public class ChatService : IChatService
|
|||||||
private readonly IMessageEncryptionService _encryption;
|
private readonly IMessageEncryptionService _encryption;
|
||||||
private readonly IChannelService _channelService;
|
private readonly IChannelService _channelService;
|
||||||
private readonly FileStorageService _fileStorage;
|
private readonly FileStorageService _fileStorage;
|
||||||
|
private readonly SpamGuard _spamGuard;
|
||||||
private readonly ILogger<ChatService> _logger;
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
public ChatService(
|
public ChatService(
|
||||||
@@ -29,6 +31,7 @@ public class ChatService : IChatService
|
|||||||
IMessageEncryptionService encryption,
|
IMessageEncryptionService encryption,
|
||||||
IChannelService channelService,
|
IChannelService channelService,
|
||||||
FileStorageService fileStorage,
|
FileStorageService fileStorage,
|
||||||
|
SpamGuard spamGuard,
|
||||||
ILogger<ChatService> logger)
|
ILogger<ChatService> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
@@ -38,6 +41,7 @@ public class ChatService : IChatService
|
|||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
_channelService = channelService;
|
_channelService = channelService;
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
|
_spamGuard = spamGuard;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +105,27 @@ public class ChatService : IChatService
|
|||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
// Join throttle — stops channel-cycling spam from any protocol. Only *first-time*
|
||||||
|
// joins (no membership yet) count: the client auto-joins every known channel on
|
||||||
|
// connect/reconnect, and that burst must never trip the guard.
|
||||||
|
if (_spamGuard.Enabled)
|
||||||
|
{
|
||||||
|
using var guardScope = _scopeFactory.CreateScope();
|
||||||
|
var guardDb = guardScope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var isExistingMember = await guardDb.Channels
|
||||||
|
.Where(c => c.Name == channelName)
|
||||||
|
.AnyAsync(c => guardDb.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
|
||||||
|
|
||||||
|
if (!isExistingMember)
|
||||||
|
{
|
||||||
|
var joiner = await guardDb.Users.FindAsync(userId);
|
||||||
|
var joinVerdict = _spamGuard.CheckJoin(userId, joiner?.Role ?? ServerRole.Member);
|
||||||
|
if (joinVerdict.Kind != SpamVerdictKind.Allowed)
|
||||||
|
return ([], joinVerdict.Reason, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delegate channel validation + membership (incl. password gate) to ChannelService
|
// Delegate channel validation + membership (incl. password gate) to ChannelService
|
||||||
var (success, error, passwordRequired) = await _channelService.EnsureChannelMembershipAsync(userId, channelName, password);
|
var (success, error, passwordRequired) = await _channelService.EnsureChannelMembershipAsync(userId, channelName, password);
|
||||||
if (!success)
|
if (!success)
|
||||||
@@ -121,7 +146,8 @@ public class ChatService : IChatService
|
|||||||
{
|
{
|
||||||
presence = new UserPresenceDto(
|
presence = new UserPresenceDto(
|
||||||
user.Username, user.DisplayName, user.NicknameColor,
|
user.Username, user.DisplayName, user.NicknameColor,
|
||||||
user.Status, user.StatusMessage, user.Role);
|
user.Status, user.StatusMessage, user.Role,
|
||||||
|
_presenceTracker.IsIrcOnly(user.Username));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -150,7 +176,7 @@ public class ChatService : IChatService
|
|||||||
_logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
|
_logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
|
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
@@ -197,6 +223,36 @@ public class ChatService : IChatService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spam guard — operates on the stored content (ciphertext for E2E rooms), never decrypts
|
||||||
|
if (sender is not null)
|
||||||
|
{
|
||||||
|
var verdict = _spamGuard.CheckMessage(userId, sender.Role, plaintext);
|
||||||
|
switch (verdict.Kind)
|
||||||
|
{
|
||||||
|
case SpamVerdictKind.AutoMute:
|
||||||
|
// Escalate through the normal timed-mute machinery: moderation endpoints
|
||||||
|
// list it and MuteExpirationService lifts it. Issued by the server itself.
|
||||||
|
sender.IsMuted = true;
|
||||||
|
sender.MutedUntil = DateTimeOffset.UtcNow.Add(verdict.MuteDuration);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
_logger.LogWarning("Spam protection auto-muted {User} for {Minutes} minutes in '{Channel}'",
|
||||||
|
username, (int)verdict.MuteDuration.TotalMinutes, channelName);
|
||||||
|
return $"You have been automatically muted for {(int)verdict.MuteDuration.TotalMinutes} minutes (spam protection).";
|
||||||
|
|
||||||
|
case SpamVerdictKind.Rejected:
|
||||||
|
return verdict.Reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replies must target an existing message in the same channel
|
||||||
|
Message? replyTarget = null;
|
||||||
|
if (replyToMessageId is { } replyId)
|
||||||
|
{
|
||||||
|
replyTarget = await db.Messages.FirstOrDefaultAsync(m => m.Id == replyId && m.ChannelId == channel.Id);
|
||||||
|
if (replyTarget is null)
|
||||||
|
return "The message you're replying to no longer exists.";
|
||||||
|
}
|
||||||
|
|
||||||
// Attempt to fetch link embeds for URLs in the plaintext message
|
// Attempt to fetch link embeds for URLs in the plaintext message
|
||||||
List<EmbedDto>? embeds = null;
|
List<EmbedDto>? embeds = null;
|
||||||
try
|
try
|
||||||
@@ -222,6 +278,7 @@ public class ChatService : IChatService
|
|||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = username,
|
SenderUsername = username,
|
||||||
EmbedJson = dbEmbedJson,
|
EmbedJson = dbEmbedJson,
|
||||||
|
ReplyToMessageId = replyTarget?.Id,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Messages.Add(message);
|
db.Messages.Add(message);
|
||||||
@@ -236,9 +293,11 @@ public class ChatService : IChatService
|
|||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
message.SentAt,
|
message.SentAt,
|
||||||
Embeds: embeds);
|
Embeds: embeds,
|
||||||
|
SenderDisplayName: sender?.DisplayName,
|
||||||
|
ReplyTo: replyTarget is null ? null : BuildReplyRef(replyTarget));
|
||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto, originConnectionId));
|
||||||
|
|
||||||
_logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
|
_logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
|
||||||
return null;
|
return null;
|
||||||
@@ -256,8 +315,29 @@ public class ChatService : IChatService
|
|||||||
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
|
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the wire reference for a reply target. Plaintext snippets are truncated
|
||||||
|
/// server-side; end-to-end room ciphertext must pass through whole (a truncated blob
|
||||||
|
/// can't be decrypted), so the client truncates those after decrypting.
|
||||||
|
/// </summary>
|
||||||
|
private ReplyRefDto BuildReplyRef(Message target)
|
||||||
|
{
|
||||||
|
const int maxSnippetLength = 120;
|
||||||
|
|
||||||
|
// Strip the at-rest layer; E2E room content stays $RC1$ ciphertext
|
||||||
|
var plain = _encryption.Decrypt(target.Content);
|
||||||
|
if (!RoomCrypto.IsRoomCiphertext(plain) && plain.Length > maxSnippetLength)
|
||||||
|
plain = plain[..maxSnippetLength] + "…";
|
||||||
|
|
||||||
|
return new ReplyRefDto(target.Id, target.SenderUsername, _encryption.Encrypt(plain));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
|
public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
|
||||||
{
|
{
|
||||||
|
// SignalR happily binds any int to the enum parameter — reject undefined values
|
||||||
|
if (!Enum.IsDefined(status))
|
||||||
|
return "Invalid status. Use online, away, dnd, or invisible.";
|
||||||
|
|
||||||
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
||||||
return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
|
return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
|
||||||
|
|
||||||
@@ -279,7 +359,8 @@ public class ChatService : IChatService
|
|||||||
user.NicknameColor,
|
user.NicknameColor,
|
||||||
status,
|
status,
|
||||||
statusMessage,
|
statusMessage,
|
||||||
user.Role);
|
user.Role,
|
||||||
|
_presenceTracker.IsIrcOnly(user.Username));
|
||||||
|
|
||||||
var channels = _presenceTracker.GetChannelsForUser(username);
|
var channels = _presenceTracker.GetChannelsForUser(username);
|
||||||
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
|
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
|
||||||
@@ -295,16 +376,20 @@ public class ChatService : IChatService
|
|||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
return await db.Users
|
var users = await db.Users
|
||||||
.Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible)
|
.Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible)
|
||||||
.Select(u => new UserPresenceDto(
|
.ToListAsync();
|
||||||
|
|
||||||
|
// IsIrcOnly comes from the in-memory tracker, so map outside the EF query
|
||||||
|
return users.Select(u => new UserPresenceDto(
|
||||||
u.Username,
|
u.Username,
|
||||||
u.DisplayName,
|
u.DisplayName,
|
||||||
u.NicknameColor,
|
u.NicknameColor,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.StatusMessage,
|
u.StatusMessage,
|
||||||
u.Role))
|
u.Role,
|
||||||
.ToListAsync();
|
_presenceTracker.IsIrcOnly(u.Username)))
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task BroadcastMessageAsync(string channelName, MessageDto message)
|
public Task BroadcastMessageAsync(string channelName, MessageDto message)
|
||||||
@@ -313,6 +398,9 @@ public class ChatService : IChatService
|
|||||||
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
|
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
|
||||||
=> BroadcastToAllAsync(b => b.SendChannelUpdatedAsync(channel, channelName));
|
=> BroadcastToAllAsync(b => b.SendChannelUpdatedAsync(channel, channelName));
|
||||||
|
|
||||||
|
public Task BroadcastChannelDeletedAsync(string channelName)
|
||||||
|
=> BroadcastToAllAsync(b => b.SendChannelDeletedAsync(channelName));
|
||||||
|
|
||||||
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
||||||
{
|
{
|
||||||
foreach (var broadcaster in _broadcasters)
|
foreach (var broadcaster in _broadcasters)
|
||||||
@@ -372,19 +460,33 @@ public class ChatService : IChatService
|
|||||||
if (channel is null)
|
if (channel is null)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
|
// Left join: tombstoned messages (deleted accounts, SenderUserId cleared) must
|
||||||
|
// still appear in history — an inner join would silently drop them.
|
||||||
var raw = await db.Messages
|
var raw = await db.Messages
|
||||||
.Where(m => m.ChannelId == channel.Id)
|
.Where(m => m.ChannelId == channel.Id)
|
||||||
.OrderByDescending(m => m.SentAt)
|
.OrderByDescending(m => m.SentAt)
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(count)
|
.Take(count)
|
||||||
.Join(db.Users,
|
.GroupJoin(db.Users,
|
||||||
m => m.SenderUserId,
|
m => m.SenderUserId,
|
||||||
u => u.Id,
|
u => u.Id,
|
||||||
(m, u) => new { m, u.NicknameColor })
|
(m, users) => new { m, users })
|
||||||
|
.SelectMany(x => x.users.DefaultIfEmpty(),
|
||||||
|
(x, u) => new { x.m, NicknameColor = u != null ? u.NicknameColor : null, DisplayName = u != null ? u.DisplayName : null })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
raw.Reverse();
|
raw.Reverse();
|
||||||
|
|
||||||
|
// Reply targets referenced by this batch, for quote snippets
|
||||||
|
var replyIds = raw
|
||||||
|
.Where(x => x.m.ReplyToMessageId.HasValue)
|
||||||
|
.Select(x => x.m.ReplyToMessageId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var replyTargets = replyIds.Count > 0
|
||||||
|
? (await db.Messages.Where(m => replyIds.Contains(m.Id)).ToListAsync()).ToDictionary(m => m.Id)
|
||||||
|
: new Dictionary<Guid, Message>();
|
||||||
|
|
||||||
var messageIds = raw.Select(x => x.m.Id).ToList();
|
var messageIds = raw.Select(x => x.m.Id).ToList();
|
||||||
var attachmentsByMessage = (await db.Attachments
|
var attachmentsByMessage = (await db.Attachments
|
||||||
.Where(a => messageIds.Contains(a.MessageId))
|
.Where(a => messageIds.Contains(a.MessageId))
|
||||||
@@ -448,7 +550,11 @@ public class ChatService : IChatService
|
|||||||
channelName,
|
channelName,
|
||||||
x.m.SentAt,
|
x.m.SentAt,
|
||||||
attachments,
|
attachments,
|
||||||
embeds));
|
embeds,
|
||||||
|
x.DisplayName,
|
||||||
|
x.m.ReplyToMessageId is { } rid && replyTargets.TryGetValue(rid, out var replyTarget)
|
||||||
|
? BuildReplyRef(replyTarget)
|
||||||
|
: null));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazily delete the pruned messages (+ their attachment rows) as they're encountered.
|
// Lazily delete the pruned messages (+ their attachment rows) as they're encountered.
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ public sealed class FileCleanupService : BackgroundService
|
|||||||
{
|
{
|
||||||
var intervalHours = _configuration.GetValue("Storage:CleanupIntervalHours", 1);
|
var intervalHours = _configuration.GetValue("Storage:CleanupIntervalHours", 1);
|
||||||
var retentionDays = _configuration.GetValue("Storage:RetentionDays", 30);
|
var retentionDays = _configuration.GetValue("Storage:RetentionDays", 30);
|
||||||
var storagePath = _configuration["Storage:Path"]
|
var configuredPath = _configuration["Storage:Path"];
|
||||||
?? Path.Combine(AppContext.BaseDirectory, "uploads");
|
var storagePath = !string.IsNullOrWhiteSpace(configuredPath)
|
||||||
|
? configuredPath
|
||||||
|
: Path.Combine(AppContext.BaseDirectory, "uploads");
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"File cleanup service started — interval: {Hours}h, retention: {Days}d, path: {Path}",
|
"File cleanup service started — interval: {Hours}h, retention: {Days}d, path: {Path}",
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ public class FileStorageService
|
|||||||
|
|
||||||
public FileStorageService(IConfiguration configuration)
|
public FileStorageService(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_storagePath = configuration["Storage:Path"]
|
// Empty string (example config placeholder) means "unset", same as missing
|
||||||
?? Path.Combine(AppContext.BaseDirectory, "uploads");
|
var configured = configuration["Storage:Path"];
|
||||||
|
_storagePath = !string.IsNullOrWhiteSpace(configured)
|
||||||
|
? configured
|
||||||
|
: Path.Combine(AppContext.BaseDirectory, "uploads");
|
||||||
|
|
||||||
if (!Directory.Exists(_storagePath))
|
if (!Directory.Exists(_storagePath))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
|
|
||||||
namespace EchoHub.Server.Services;
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
@@ -174,6 +175,20 @@ public class PresenceTracker
|
|||||||
return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0;
|
return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the user is online exclusively through the IRC gateway. A user who also has a
|
||||||
|
/// native client connected has full features, so they don't count as IRC-only.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsIrcOnly(string username)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _userConnections.TryGetValue(username, out var connections)
|
||||||
|
&& connections.Count > 0
|
||||||
|
&& connections.All(c => c.StartsWith(HubConstants.IrcConnectionIdPrefix, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public int GetOnlineUserCount()
|
public int GetOnlineUserCount()
|
||||||
{
|
{
|
||||||
return _userConnections.Count;
|
return _userConnections.Count;
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ public class SignalRBroadcaster : IChatBroadcaster
|
|||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
// The exclusion only applies to the IRC gateway (SignalR clients render their own
|
||||||
|
// message from the broadcast echo), so the id is ignored here.
|
||||||
|
public Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null)
|
||||||
=> HubContext.Clients.Group(channelName).ReceiveMessage(message);
|
=> HubContext.Clients.Group(channelName).ReceiveMessage(message);
|
||||||
|
|
||||||
public Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
|
public Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
|
||||||
@@ -63,6 +65,9 @@ public class SignalRBroadcaster : IChatBroadcaster
|
|||||||
public Task SendMessageDeletedAsync(string channelName, Guid messageId)
|
public Task SendMessageDeletedAsync(string channelName, Guid messageId)
|
||||||
=> HubContext.Clients.Group(channelName).MessageDeleted(channelName, messageId);
|
=> HubContext.Clients.Group(channelName).MessageDeleted(channelName, messageId);
|
||||||
|
|
||||||
|
public Task SendChannelDeletedAsync(string channelName)
|
||||||
|
=> HubContext.Clients.All.ChannelDeleted(channelName);
|
||||||
|
|
||||||
public Task SendChannelNukedAsync(string channelName)
|
public Task SendChannelNukedAsync(string channelName)
|
||||||
=> HubContext.Clients.Group(channelName).ChannelNuked(channelName);
|
=> HubContext.Clients.Group(channelName).ChannelNuked(channelName);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
public enum SpamVerdictKind
|
||||||
|
{
|
||||||
|
Allowed,
|
||||||
|
Rejected,
|
||||||
|
|
||||||
|
/// <summary>The user crossed the violation threshold — the caller should apply a timed mute.</summary>
|
||||||
|
AutoMute,
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct SpamVerdict(SpamVerdictKind Kind, string? Reason = null, TimeSpan MuteDuration = default)
|
||||||
|
{
|
||||||
|
public static readonly SpamVerdict Allowed = new(SpamVerdictKind.Allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory, per-user spam protection consulted from <see cref="ChatService"/> (messages,
|
||||||
|
/// joins) and <see cref="ChannelService"/> (channel creation), so every ingress protocol —
|
||||||
|
/// SignalR and IRC alike — shares the same limits. State is per-process and never persisted;
|
||||||
|
/// auto-mutes land in the normal mute store. Operates only on content the server already
|
||||||
|
/// stores (for end-to-end encrypted rooms that is ciphertext) — nothing is decrypted.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpamGuard
|
||||||
|
{
|
||||||
|
private readonly SpamOptions _options;
|
||||||
|
private readonly Lock _lock = new();
|
||||||
|
private readonly Dictionary<Guid, UserState> _users = [];
|
||||||
|
|
||||||
|
// Lazy stale-state pruning so the dictionary can't grow unbounded on busy servers
|
||||||
|
private const int PruneThreshold = 512;
|
||||||
|
private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
|
public SpamGuard(SpamOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Enabled => _options.Enabled;
|
||||||
|
|
||||||
|
private sealed class UserState
|
||||||
|
{
|
||||||
|
public readonly Queue<DateTimeOffset> MessageTimes = new();
|
||||||
|
public readonly Queue<DateTimeOffset> JoinTimes = new();
|
||||||
|
public readonly Queue<DateTimeOffset> CreateTimes = new();
|
||||||
|
public readonly Queue<DateTimeOffset> Violations = new();
|
||||||
|
public string? LastContent;
|
||||||
|
public int RepeatCount;
|
||||||
|
public DateTimeOffset LastSeen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks a message send. Rejections count as violations; enough violations inside the
|
||||||
|
/// violation window escalate to <see cref="SpamVerdictKind.AutoMute"/> (evaluated only on
|
||||||
|
/// rejected messages, so a clean message never mutes anyone).
|
||||||
|
/// </summary>
|
||||||
|
public SpamVerdict CheckMessage(Guid userId, ServerRole role, string content, DateTimeOffset? nowOverride = null)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled || role >= ServerRole.Mod)
|
||||||
|
return SpamVerdict.Allowed;
|
||||||
|
|
||||||
|
var now = nowOverride ?? DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var state = GetState(userId, now);
|
||||||
|
|
||||||
|
// Flood: attempts count too — a client hammering retries must actually pause
|
||||||
|
Prune(state.MessageTimes, now - TimeSpan.FromSeconds(_options.WindowSeconds));
|
||||||
|
state.MessageTimes.Enqueue(now);
|
||||||
|
if (state.MessageTimes.Count > _options.MaxMessagesPerWindow)
|
||||||
|
return Reject(state, now, "You're sending messages too fast — slow down.");
|
||||||
|
|
||||||
|
// Duplicates: identical (trimmed, case-insensitive) content repeated back-to-back
|
||||||
|
var normalized = content.Trim().ToLowerInvariant();
|
||||||
|
if (normalized == state.LastContent)
|
||||||
|
{
|
||||||
|
if (state.RepeatCount >= _options.MaxDuplicateMessages)
|
||||||
|
return Reject(state, now, "Duplicate message — say something new.");
|
||||||
|
state.RepeatCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
state.LastContent = normalized;
|
||||||
|
state.RepeatCount = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SpamVerdict.Allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Checks a channel join. Rejections count as violations but never auto-mute.</summary>
|
||||||
|
public SpamVerdict CheckJoin(Guid userId, ServerRole role, DateTimeOffset? nowOverride = null)
|
||||||
|
{
|
||||||
|
return CheckWindow(userId, role, nowOverride,
|
||||||
|
s => s.JoinTimes,
|
||||||
|
TimeSpan.FromSeconds(_options.JoinWindowSeconds),
|
||||||
|
_options.MaxJoinsPerWindow,
|
||||||
|
"You're joining channels too fast — slow down.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Checks a channel creation. Rejections count as violations but never auto-mute.</summary>
|
||||||
|
public SpamVerdict CheckChannelCreate(Guid userId, ServerRole role, DateTimeOffset? nowOverride = null)
|
||||||
|
{
|
||||||
|
return CheckWindow(userId, role, nowOverride,
|
||||||
|
s => s.CreateTimes,
|
||||||
|
TimeSpan.FromMinutes(_options.ChannelCreateWindowMinutes),
|
||||||
|
_options.MaxChannelCreatesPerWindow,
|
||||||
|
"You're creating channels too fast — try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpamVerdict CheckWindow(Guid userId, ServerRole role, DateTimeOffset? nowOverride,
|
||||||
|
Func<UserState, Queue<DateTimeOffset>> queueSelector, TimeSpan window, int max, string reason)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled || role >= ServerRole.Mod)
|
||||||
|
return SpamVerdict.Allowed;
|
||||||
|
|
||||||
|
var now = nowOverride ?? DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var state = GetState(userId, now);
|
||||||
|
var queue = queueSelector(state);
|
||||||
|
|
||||||
|
Prune(queue, now - window);
|
||||||
|
queue.Enqueue(now);
|
||||||
|
if (queue.Count > max)
|
||||||
|
{
|
||||||
|
RecordViolation(state, now);
|
||||||
|
return new SpamVerdict(SpamVerdictKind.Rejected, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SpamVerdict.Allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a violation and decides between plain rejection and auto-mute escalation.
|
||||||
|
/// On escalation the violation history is cleared so the user starts fresh post-mute.
|
||||||
|
/// </summary>
|
||||||
|
private SpamVerdict Reject(UserState state, DateTimeOffset now, string reason)
|
||||||
|
{
|
||||||
|
RecordViolation(state, now);
|
||||||
|
|
||||||
|
if (_options.AutoMuteMinutes > 0 && state.Violations.Count >= _options.ViolationThreshold)
|
||||||
|
{
|
||||||
|
state.Violations.Clear();
|
||||||
|
return new SpamVerdict(SpamVerdictKind.AutoMute, reason,
|
||||||
|
TimeSpan.FromMinutes(_options.AutoMuteMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SpamVerdict(SpamVerdictKind.Rejected, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecordViolation(UserState state, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
Prune(state.Violations, now - TimeSpan.FromMinutes(_options.ViolationWindowMinutes));
|
||||||
|
state.Violations.Enqueue(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserState GetState(Guid userId, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (_users.Count > PruneThreshold)
|
||||||
|
{
|
||||||
|
foreach (var stale in _users.Where(kv => now - kv.Value.LastSeen > StaleAfter).Select(kv => kv.Key).ToList())
|
||||||
|
_users.Remove(stale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_users.TryGetValue(userId, out var state))
|
||||||
|
{
|
||||||
|
state = new UserState();
|
||||||
|
_users[userId] = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.LastSeen = now;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Prune(Queue<DateTimeOffset> queue, DateTimeOffset cutoff)
|
||||||
|
{
|
||||||
|
while (queue.Count > 0 && queue.Peek() < cutoff)
|
||||||
|
queue.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,13 +11,24 @@ namespace EchoHub.Server.Services;
|
|||||||
public class UserService : IUserService
|
public class UserService : IUserService
|
||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
public UserService(IServiceScopeFactory scopeFactory)
|
public UserService(IServiceScopeFactory scopeFactory, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
|
_configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null)
|
/// <summary>Registration mode from config: "open" (default), "invite", or "closed".</summary>
|
||||||
|
public string RegistrationMode =>
|
||||||
|
(_configuration["Server:Registration"] ?? "open").Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"invite" => "invite",
|
||||||
|
"closed" => "closed",
|
||||||
|
_ => "open",
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null, string? inviteCode = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required.");
|
return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required.");
|
||||||
@@ -35,6 +46,10 @@ public class UserService : IUserService
|
|||||||
|
|
||||||
var normalizedUsername = username.ToLowerInvariant().Trim();
|
var normalizedUsername = username.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
// Reserved: deleted accounts' messages are re-attributed to this name
|
||||||
|
if (normalizedUsername == Controllers.UsersController.DeletedUserName)
|
||||||
|
return UserOperationResult.Fail(UserError.ValidationFailed, "This username is reserved.");
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
@@ -43,6 +58,24 @@ public class UserService : IUserService
|
|||||||
|
|
||||||
var isFirstUser = !await db.Users.AnyAsync();
|
var isFirstUser = !await db.Users.AnyAsync();
|
||||||
|
|
||||||
|
// Registration gate. The very first account (server owner bootstrap) is always allowed —
|
||||||
|
// otherwise a fresh invite/closed server could never mint its first admin.
|
||||||
|
if (!isFirstUser)
|
||||||
|
{
|
||||||
|
switch (RegistrationMode)
|
||||||
|
{
|
||||||
|
case "closed":
|
||||||
|
return UserOperationResult.Fail(UserError.ValidationFailed,
|
||||||
|
"Registration is closed on this server.");
|
||||||
|
|
||||||
|
case "invite":
|
||||||
|
var inviteError = await TryConsumeInviteAsync(db, inviteCode);
|
||||||
|
if (inviteError is not null)
|
||||||
|
return UserOperationResult.Fail(UserError.ValidationFailed, inviteError);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var user = new User
|
var user = new User
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -58,6 +91,32 @@ public class UserService : IUserService
|
|||||||
return UserOperationResult.Success(ToProfileDto(user));
|
return UserOperationResult.Success(ToProfileDto(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates and consumes one use of an invite code. Returns an error message, or null on
|
||||||
|
/// success. The increment is a guarded UPDATE so two racing registrations can't both take
|
||||||
|
/// a code's last use.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string?> TryConsumeInviteAsync(EchoHubDbContext db, string? inviteCode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(inviteCode))
|
||||||
|
return "Registration is invite-only on this server. An invite code is required.";
|
||||||
|
|
||||||
|
var code = inviteCode.Trim().ToUpperInvariant();
|
||||||
|
var invite = await db.InviteCodes.FirstOrDefaultAsync(i => i.Code == code);
|
||||||
|
|
||||||
|
if (invite is null || invite.UseCount >= invite.MaxUses)
|
||||||
|
return "Invalid invite code.";
|
||||||
|
|
||||||
|
if (invite.ExpiresAt.HasValue && invite.ExpiresAt.Value <= DateTimeOffset.UtcNow)
|
||||||
|
return "This invite code has expired.";
|
||||||
|
|
||||||
|
var consumed = await db.InviteCodes
|
||||||
|
.Where(i => i.Id == invite.Id && i.UseCount < i.MaxUses)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(i => i.UseCount, i => i.UseCount + 1));
|
||||||
|
|
||||||
|
return consumed == 1 ? null : "Invalid invite code.";
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<UserOperationResult> AuthenticateUserAsync(string username, string password)
|
public async Task<UserOperationResult> AuthenticateUserAsync(string username, string password)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
|
|||||||
@@ -14,12 +14,35 @@
|
|||||||
"PublicServer": false,
|
"PublicServer": false,
|
||||||
"PublicHosts": [],
|
"PublicHosts": [],
|
||||||
"Tags": [],
|
"Tags": [],
|
||||||
"Admins": []
|
"Admins": [],
|
||||||
|
"Registration": "open",
|
||||||
|
"DirectoryClaimPath": ""
|
||||||
},
|
},
|
||||||
"Storage": {
|
"Storage": {
|
||||||
|
"Path": "",
|
||||||
"CleanupIntervalHours": 1,
|
"CleanupIntervalHours": 1,
|
||||||
"RetentionDays": 30
|
"RetentionDays": 30
|
||||||
},
|
},
|
||||||
|
"Uploads": {
|
||||||
|
"MaxFileSizeMB": 100,
|
||||||
|
"MaxImageSizeMB": 10,
|
||||||
|
"MaxAudioSizeMB": 10,
|
||||||
|
"MaxAvatarSizeMB": 2,
|
||||||
|
"MaxAttachmentsPerMessage": 10
|
||||||
|
},
|
||||||
|
"Spam": {
|
||||||
|
"Enabled": true,
|
||||||
|
"MaxMessagesPerWindow": 8,
|
||||||
|
"WindowSeconds": 5,
|
||||||
|
"MaxDuplicateMessages": 3,
|
||||||
|
"AutoMuteMinutes": 5,
|
||||||
|
"ViolationThreshold": 5,
|
||||||
|
"ViolationWindowMinutes": 5,
|
||||||
|
"MaxJoinsPerWindow": 25,
|
||||||
|
"JoinWindowSeconds": 30,
|
||||||
|
"MaxChannelCreatesPerWindow": 3,
|
||||||
|
"ChannelCreateWindowMinutes": 10
|
||||||
|
},
|
||||||
"Encryption": {
|
"Encryption": {
|
||||||
"Key": "",
|
"Key": "",
|
||||||
"EncryptDatabase": false
|
"EncryptDatabase": false
|
||||||
@@ -32,7 +55,11 @@
|
|||||||
"TlsCertPath": "",
|
"TlsCertPath": "",
|
||||||
"TlsCertPassword": "",
|
"TlsCertPassword": "",
|
||||||
"ServerName": "echohub",
|
"ServerName": "echohub",
|
||||||
"Motd": "Welcome to EchoHub IRC Gateway!"
|
"Motd": "Welcome to EchoHub IRC Gateway!",
|
||||||
|
"PublicBaseUrl": ""
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": []
|
||||||
},
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user