Compare commits

47 Commits
Author SHA1 Message Date
Hue 4a598aa8a8 Merge pull request #31 from HueByte/dev
Dev merge
2026-02-24 22:01:59 +01:00
Hue ed7d0a73a3 Merge branch 'master' into dev 2026-02-24 22:00:48 +01:00
HueByte 6af85559cb chore: update todo list with completed tasks 2026-02-24 21:59:42 +01:00
Stone_Red 43f1b97244 Merge pull request #26 from HueByte/todo_list
Add  todo list
2026-02-24 21:51:42 +01:00
Stone_Red 9baa3ff566 chore: fix lint errors in todo.md 2026-02-24 21:50:44 +01:00
Stone_Red 0330f24e45 Add todo list 2026-02-24 21:48:12 +01:00
Stone_Red ac98845c69 Merge pull request #28 from HueByte/dev_fix_ghost_channel_on_join
fix: ghost channel when trying to join a channel that doesn't exist
2026-02-24 21:47:53 +01:00
Stone_Red d4f540285b chore: add ghost channel fixes to changelog 2026-02-24 21:45:33 +01:00
Stone_Red 3896640ac7 refactor: Move JoinChannelResult to the correct position in ChatDtos.cs 2026-02-24 21:45:33 +01:00
Stone_Red 29bfd87d8b fix: ghost channel when trying to join a channel that doesn't exist 2026-02-24 21:45:33 +01:00
Stone_Red b0adb55b8f refactor: Move JoinChannelResult to the correct position in ChatDtos.cs 2026-02-24 21:45:33 +01:00
Stone_Red 24a5bdb9a1 fix: ghost channel when trying to join a channel that doesn't exist 2026-02-24 21:45:33 +01:00
HueByte 5391d2cb1c fix: improve role tag formatting and handle empty role cases in user status display 2026-02-24 21:44:43 +01:00
HueByte 62996f85e9 fix: add space between role tag and username in status display 2026-02-24 21:15:41 +01:00
Hue ac5348dbb0 Merge pull request #30 from HueByte/dev_bugs_attack
Dev bugs attack
2026-02-24 21:05:11 +01:00
Hue ad5627a4bb Merge branch 'dev' into dev_bugs_attack 2026-02-24 21:03:23 +01:00
Hue c7a5829fac Merge pull request #29 from HueByte/dev_pipelines
Dev pipelines
2026-02-24 21:02:00 +01:00
HueByte a0d9d86956 fix: update markdownlint configuration to allow compact table pipe style 2026-02-24 21:00:51 +01:00
HueByte 0c16f44db6 feat: enhance user presence and channel interaction features
- Fix userlist not refreshing after creating a new channel.
- Implement unmute timer with a background job to automatically unmute users.
- Improve userlist display by filtering invisible users and ensuring proper transitions between statuses.
- Add clickable usernames, @mentions, and #channels for easier navigation.
- Embed theme colors from source sites for a more cohesive UI.
- Introduce a stateful userlist that updates incrementally via SignalR events.
- Restrict auto-opening of files to safe types only, enhancing security.
- Refactor user management into a dedicated service to reduce code duplication.
- Add a MuteExpirationService to handle timed mutes.
- Update documentation with Mermaid diagrams for major flows.
2026-02-24 20:56:40 +01:00
HueByte fb4f6c34ed feat: add detailed documentation for authentication, connection, messaging, channels, moderation, and media flows 2026-02-24 20:50:20 +01:00
Hue 8647b05c12 Merge pull request #27 from HueByte/dev_distribution_update
Dev distribution update
2026-02-24 19:51:11 +01:00
Hue e2bab3d0d9 Merge branch 'dev' into dev_distribution_update 2026-02-24 19:50:11 +01:00
HueByte 6295831045 feat: update nuspec metadata and add PATH setup functionality for terminal access 2026-02-24 19:44:55 +01:00
HueByte 045515369c feat: add Chocolatey package and Linux/macOS install script with automated publishing 2026-02-24 19:44:46 +01:00
Stone_Red fe0e31f9a0 Merge pull request #25 from HueByte/dev_improve_word_wrapping
feat: improve word wrapping in ChatLine
2026-02-24 13:54:36 +01:00
Stone_Red 3273e62b37 feat: improve word wrapping in ChatLine 2026-02-24 13:53:40 +01:00
Hue cf5fca5772 Merge pull request #24 from HueByte/dev_update_adjustments
Dev update adjustments
2026-02-23 19:46:03 +01:00
HueByte 38df05df41 feat: implement rollback functionality with pre-update backup and recovery options | Rebase 2026-02-23 18:15:16 +01:00
Stone_Red 760693befe Merge pull request #22 from HueByte/dev_manual_update_entry
feat: Add "Check for Updates" menu item
2026-02-23 17:50:12 +01:00
Stone_Red 241fee67e8 chore: update AlwaysUpToDate package to version 2.0.2.20250223 2026-02-23 17:20:38 +01:00
Stone_Red 0e3dd932af feat: Add "Check for Updates" menu item 2026-02-23 17:20:38 +01:00
Hue 81b09b1af7 Merge pull request #23 from HueByte/dev_user_irc_improvements
Dev user irc improvements
2026-02-23 15:58:37 +01:00
HueByte 94b31b6056 refactor: replace FakeChatService with FakeUserService for user authentication and profile handling in tests 2026-02-23 15:52:18 +01:00
HueByte 6e76065dcb fix: correct markdown table formatting and specify code block type in Docker documentation 2026-02-23 14:53:58 +01:00
HueByte bdcff74ad5 Refactor user management: Extract IUserService and UserService, consolidate user registration, authentication, and profile management. Fix memory leaks in ApiClient, enhance connection management, and improve error handling in AuthController and UsersController. Update IRC command handling to utilize IUserService for user operations. 2026-02-23 14:51:24 +01:00
HueByte 27a25b1b43 feat: implement user registration and update SASL authentication handling 2026-02-23 14:51:04 +01:00
HueByte 28c4b2993f feat: handle SASL authentication abort scenario in IrcCommandHandler 2026-02-23 10:57:12 +01:00
HueByte 3c760b0bd8 feat: improve IRC command handling with debug logging for incoming messages 2026-02-23 10:54:02 +01:00
HueByte c2a8e9cfc8 feat: enhance SASL authentication logging for better traceability 2026-02-23 10:40:56 +01:00
HueByte 45fd382b13 feat: add Docker support with environment configuration and entrypoint script 2026-02-22 18:12:06 +01:00
HueByte 039390c8b0 feat: add Docker workflow for building and pushing multi-arch server image to GHCR 2026-02-22 18:03:25 +01:00
HueByte c5be63db25 feat: add Docker support for EchoHub.Server with Dockerfile and docker-compose.yml 2026-02-22 17:58:18 +01:00
HueByte 993bb1f973 refactor: enhance audio playback service with semaphore locking for thread safety 2026-02-22 16:43:05 +01:00
Hue 94f968ccf9 Merge pull request #21 from HueByte/dev
Dev
2026-02-22 16:31:37 +01:00
HueByte 4f96b8d986 refactor: consolidate and reorganize code structure; extract classes and improve readability 2026-02-22 16:25:06 +01:00
HueByte fbb2958fdd refactor: remove Terminal.Gui submodule and switch to NuGet package; update version to 0.2.7 2026-02-22 16:23:19 +01:00
HueByte 62e9d7fff5 fix: optimize user fetching after connection failure handling 2026-02-22 16:02:15 +01:00
79 changed files with 3232 additions and 450 deletions
+39
View File
@@ -0,0 +1,39 @@
# EchoHub Server Configuration
# Copy this file to .env and customize as needed: cp .env.example .env
# These override appsettings.json via ASP.NET Core's configuration hierarchy.
# ── Server ───────────────────────────────────────────────────────────
Server__Name=My EchoHub Server
Server__Description=A self-hosted EchoHub chat server
Server__PublicServer=false
# Server__PublicHost=echohub.example.com
# Server__Admins__0=adminUsername
# ── JWT ──────────────────────────────────────────────────────────────
# Auto-generated on first run if left empty. Only set if you need a stable secret across containers.
# Jwt__Secret=
# Jwt__Issuer=EchoHub.Server
# Jwt__Audience=EchoHub.Client
# ── Encryption ───────────────────────────────────────────────────────
# Auto-generated on first run if left empty.
# Encryption__Key=
# Encryption__EncryptDatabase=false
# ── Storage ──────────────────────────────────────────────────────────
# Defaults are set in the Dockerfile to use /app/data for persistence.
# Storage__CleanupIntervalHours=1
# Storage__RetentionDays=30
# ── IRC Gateway ──────────────────────────────────────────────────────
Irc__Enabled=false
# Irc__Port=6667
# Irc__TlsEnabled=false
# Irc__TlsPort=6697
# Irc__TlsCertPath=
# Irc__TlsCertPassword=
# Irc__ServerName=echohub
# Irc__Motd=Welcome to EchoHub IRC Gateway!
# ── Logging ──────────────────────────────────────────────────────────
# Serilog__MinimumLevel__Default=Information
+1 -12
View File
@@ -22,12 +22,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
- name: Remove submodule NuGet config
run: rm -f src/Terminal.Gui/nuget.config
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
@@ -35,7 +29,7 @@ jobs:
dotnet-version: '10.0.x'
- name: Check formatting
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic --exclude src/Terminal.Gui/
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
build-and-test:
name: Build & Test
@@ -44,11 +38,6 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
- name: Remove submodule NuGet config
run: rm -f src/Terminal.Gui/nuget.config
- name: Check for src/ changes
id: changes
+92
View File
@@ -0,0 +1,92 @@
name: Docker
on:
push:
branches: [master]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/huebyte/echohub-server
jobs:
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for src/ changes
id: changes
env:
BEFORE: ${{ github.event.before }}
run: |
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
echo "src_changed=true" >> "$GITHUB_OUTPUT"
else
CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Read version
if: steps.changes.outputs.src_changed == 'true'
id: version
run: |
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if image tag exists
if: steps.changes.outputs.src_changed == 'true'
id: check_image
run: |
TAG="${{ steps.version.outputs.tag }}"
if docker manifest inspect "${{ env.IMAGE }}:${TAG}" &>/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
if: steps.changes.outputs.src_changed == 'true' && steps.check_image.outputs.exists == 'false'
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: steps.changes.outputs.src_changed == 'true' && steps.check_image.outputs.exists == 'false'
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: steps.changes.outputs.src_changed == 'true' && steps.check_image.outputs.exists == 'false'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
if: steps.changes.outputs.src_changed == 'true' && steps.check_image.outputs.exists == 'false'
uses: docker/build-push-action@v6
with:
context: ./src
file: ./src/EchoHub.Server/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ steps.version.outputs.tag }}
labels: |
org.opencontainers.image.title=EchoHub Server
org.opencontainers.image.description=Self-hosted IRC-style chat server
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
- name: Skip notice
if: steps.changes.outputs.src_changed != 'true' || steps.check_image.outputs.exists == 'true'
run: echo "⏭️ Skipped — no src/ changes or image tag already exists."
-6
View File
@@ -16,12 +16,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
- name: Remove submodule NuGet config
run: rm -f src/Terminal.Gui/nuget.config
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
+32 -6
View File
@@ -16,11 +16,6 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
- name: Remove submodule NuGet config
run: rm -f src/Terminal.Gui/nuget.config
- name: Check for src/ changes
id: changes
@@ -76,6 +71,10 @@ jobs:
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true -o publish/server-osx-arm64
- name: Publish Server linux-arm64
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-arm64 --self-contained true -o publish/server-linux-arm64
- name: Publish Client win-x64
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64
@@ -92,6 +91,10 @@ jobs:
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -o publish/client-osx-arm64
- name: Publish Client linux-arm64
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-arm64 --self-contained true -o publish/client-linux-arm64
- name: Zip artifacts
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: |
@@ -100,10 +103,12 @@ jobs:
zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/
zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/
zip -r ../EchoHub-Server-osx-arm64.zip server-osx-arm64/
zip -r ../EchoHub-Server-linux-arm64.zip server-linux-arm64/
zip -r ../EchoHub-Client-win-x64.zip client-win-x64/
zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/
zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/
zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/
zip -r ../EchoHub-Client-linux-arm64.zip client-linux-arm64/
- name: Build release notes
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
@@ -144,9 +149,30 @@ jobs:
EchoHub-Server-linux-x64.zip \
EchoHub-Server-osx-x64.zip \
EchoHub-Server-osx-arm64.zip \
EchoHub-Server-linux-arm64.zip \
EchoHub-Client-win-x64.zip \
EchoHub-Client-linux-x64.zip \
EchoHub-Client-osx-x64.zip \
EchoHub-Client-osx-arm64.zip
EchoHub-Client-osx-arm64.zip \
EchoHub-Client-linux-arm64.zip
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Chocolatey package
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: |
VERSION="${{ steps.version.outputs.version }}"
CHECKSUM=$(sha256sum EchoHub-Client-win-x64.zip | awk '{print $1}')
# Stamp version and checksum into package templates
sed -i "s/__VERSION__/$VERSION/g" packaging/choco/echohub.nuspec
sed -i "s/__VERSION__/$VERSION/g" packaging/choco/tools/chocolateyInstall.ps1
sed -i "s/__CHECKSUM64__/$CHECKSUM/g" packaging/choco/tools/chocolateyInstall.ps1
# Build and push the package
mkdir -p /tmp/choco-out
cd packaging/choco
choco pack echohub.nuspec --output-directory /tmp/choco-out
choco push /tmp/choco-out/echohub.*.nupkg --source https://push.chocolatey.org/ --api-key "$CHOCO_API_KEY"
env:
CHOCO_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
-3
View File
@@ -1,3 +0,0 @@
[submodule "src/Terminal.Gui"]
path = src/Terminal.Gui
url = https://github.com/HueByte/Terminal.Gui.git
+3 -1
View File
@@ -9,7 +9,9 @@
// Allow inline HTML (docfx uses it)
"MD033": false,
// Allow bare URLs
"MD034": false
"MD034": false,
// Allow compact table pipe style (flow docs use compact tables)
"MD060": false
},
"globs": ["**/*.md"],
+19
View File
@@ -0,0 +1,19 @@
services:
echohub-server:
build:
context: ./src
dockerfile: EchoHub.Server/Dockerfile
# image: ghcr.io/huebyte/echohub-server:latest # use this instead of build for pre-built images
container_name: echohub-server
restart: unless-stopped
ports:
- "5000:5000"
# - "6667:6667" # IRC (plain, no encryption)
# - "6697:6697" # IRC (TLS encrypted, preferred)
volumes:
- echohub-data:/app/data
env_file:
- .env
volumes:
echohub-data:
+130
View File
@@ -0,0 +1,130 @@
# Docker
## Quick Start
```bash
cp .env.example .env # create your config
docker compose up -d # start the server
```
On first run the server automatically generates JWT and encryption keys, creates the database, and seeds a `#general` channel. Connect with the EchoHub client to `http://localhost:5000`.
### Using a Pre-built Image
Instead of building locally, you can pull from GHCR. In `docker-compose.yml`, replace the `build` block:
```yaml
services:
echohub-server:
image: ghcr.io/huebyte/echohub-server:latest
# build:
# context: ./src
# dockerfile: EchoHub.Server/Dockerfile
```
## Configuration
All settings are configured through the `.env` file. These are ASP.NET Core environment variables that override `appsettings.json`.
| Variable | Default | Description |
| --- | --- | --- |
| `Server__Name` | My EchoHub Server | Display name for your server |
| `Server__Description` | A self-hosted EchoHub chat server | Server description |
| `Server__PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) |
| `Server__PublicHost` | *(empty)* | Public address for the directory listing |
| `Server__Admins__0` | *(empty)* | Admin username (use `__1`, `__2` for more) |
| `Jwt__Secret` | *(auto-generated)* | JWT signing key. Auto-generated on first run |
| `Encryption__Key` | *(auto-generated)* | AES encryption key. Auto-generated on first run |
| `Encryption__EncryptDatabase` | `false` | Encrypt message content in the database |
| `Storage__CleanupIntervalHours` | `1` | How often to clean expired uploads |
| `Storage__RetentionDays` | `30` | Days to keep uploaded files |
| `Irc__Enabled` | `false` | Enable the IRC gateway |
| `Irc__Port` | `6667` | IRC plain-text port |
| `Irc__TlsEnabled` | `false` | Enable IRC over TLS |
| `Irc__TlsPort` | `6697` | IRC TLS port |
| `Irc__ServerName` | `echohub` | IRC server name shown to clients |
| `Irc__Motd` | Welcome to EchoHub IRC Gateway! | Message of the day |
| `Serilog__MinimumLevel__Default` | `Information` | Log level (`Debug`, `Warning`, etc.) |
## Persistent Data
All server state lives in a single Docker volume mounted at `/app/data`:
```text
/app/data/
├── appsettings.json # generated config with JWT/encryption keys
├── echohub.db # SQLite database
├── uploads/ # uploaded files and avatars
└── logs/ # rolling log files (14-day retention)
```
### Backup
```bash
# stop the server to ensure a consistent snapshot
docker compose stop
# copy the data volume to a local directory
docker cp echohub-server:/app/data ./backup
docker compose start
```
## IRC Gateway
To enable IRC, set these in your `.env`:
```env
Irc__Enabled=true
```
Then uncomment the port in `docker-compose.yml`:
```yaml
ports:
- "5000:5000"
- "6697:6697" # IRC (TLS encrypted, preferred)
```
For TLS, also set:
```env
Irc__TlsEnabled=true
Irc__TlsCertPath=/app/data/cert.pfx
Irc__TlsCertPassword=your_password
```
Mount your certificate into the data volume or bind-mount it directly.
IRC users must have an existing EchoHub account. See [Getting Started](getting-started.md#connect-via-irc) for client connection examples.
## Updating
```bash
# if using pre-built images
docker compose pull
docker compose up -d
# if building locally
docker compose build
docker compose up -d
```
Data persists across updates since it lives in the named volume.
## Troubleshooting
**Port already in use** -- Another process is using port 5000. Change the host port in `docker-compose.yml`:
```yaml
ports:
- "8080:5000" # access via http://localhost:8080
```
**Permission denied on volume** -- The container runs as a non-root `echohub` user (UID 999). If using bind mounts instead of named volumes, ensure the directory is writable.
**View logs** -- Check the container output:
```bash
docker compose logs -f echohub-server
```
File-based logs are also available inside the volume at `/app/data/logs/`.
+40 -9
View File
@@ -1,29 +1,60 @@
# Getting Started
## Prerequisites
## Install the Client
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
### Windows (Chocolatey)
Or grab a self-contained binary from [Releases](https://github.com/HueByte/EchoHub/releases) -- no runtime needed.
```bash
choco install echohub
```
## Run the Server
### Linux / macOS
```bash
curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/install.sh | sh
```
To install a specific version or to a custom directory:
```bash
curl -sSfL .../install.sh | sh -s -- --version 0.2.8
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
```
### Manual Download
Grab a self-contained binary from [Releases](https://github.com/HueByte/EchoHub/releases) -- no runtime needed.
## Host a Server
### Docker
The quickest way to host a server:
```bash
cp .env.example .env
docker compose up -d
```
See the [Docker guide](docker.md) for configuration, pre-built images, and more.
### From Source
```bash
dotnet run --project src/EchoHub.Server
```
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download).
On first run, the server automatically:
1. Creates `appsettings.json` from the example config
2. Generates a secure JWT secret
3. Creates the SQLite database with a `#general` channel
## Run the Client
```bash
dotnet run --project src/EchoHub.Client
```
## Usage
After installing the client, run `echohub` (or `dotnet run --project src/EchoHub.Client` from source).
Connect to a server, register an account, and start chatting.
## Connect via IRC
+2
View File
@@ -1,5 +1,7 @@
- name: Getting Started
href: getting-started.md
- name: Docker
href: docker.md
- name: Architecture
href: architecture.md
- name: Encryption
+3
View File
@@ -4,6 +4,9 @@ Release history for EchoHub.
## Releases
- [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix
- [v0.2.7](v0.2.7.md) - User List Fix & Terminal.Gui NuGet Migration
- [v0.2.6](v0.2.6.md) - Major Refactoring & Code Organization
- [v0.2.5](v0.2.5.md) - Session Persistence, Auto-Updates, Audio & Transparent Theme
- [v0.2.4](v0.2.4.md) - E2E Message Encryption
- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
+6
View File
@@ -1,5 +1,11 @@
- name: Overview
href: index.md
- name: v0.2.8
href: v0.2.8.md
- name: v0.2.7
href: v0.2.7.md
- name: v0.2.6
href: v0.2.6.md
- name: v0.2.5
href: v0.2.5.md
- name: v0.2.4
+2
View File
@@ -14,6 +14,8 @@
- Moved `AsyncRunner` from project root to `Services/` with updated namespace
- Renamed `ColorHelper``HexColorHelper` to avoid namespace collision with Terminal.Gui's `ColorHelper` NuGet dependency
- Moved `hue_icon.ico` to `Client/Assets/`, removed duplicate from Server (Server now references shared icon via relative path)
- Add hex color parsing helper and implement custom list sources for channels and users
- Add dialogs for connection, channel creation, profile editing, and status management
## Infrastructure
+12
View File
@@ -0,0 +1,12 @@
# v0.2.7
## Bug Fixes
- Fix user list empty on initial connect — `FetchAndUpdateOnlineUsers` was called before `InvokeUI` set the current channel, causing an early return
## Infrastructure
- Switch Terminal.Gui from local fork submodule back to NuGet package (`2.0.0-develop.5039`) — transparent color PR merged upstream
- Remove Terminal.Gui submodule, `.gitmodules`, and root `nuget.config` workaround
- Remove `rm -f` submodule nuget.config steps and `submodules: recursive` from all 3 CI workflows (ci, docs, release)
- Remove `--exclude src/Terminal.Gui/` from format check
+57
View File
@@ -0,0 +1,57 @@
# v0.2.8
## Bug Fixes
- Fix memory leak — `HttpResponseMessage` objects never disposed in `ApiClient`, leaking TCP connections and content buffers on every API call (especially on failed connection attempts)
- Fix 401 retry leak — `AuthenticatedGetAsync`/`AuthenticatedRequestAsync` leaked the original response when retrying after token refresh
- Fix connection failure cleanup — `ConnectionManager.ConnectAsync` now properly disposes `ApiClient` and `EchoHubConnection` on any failure path (previously only cleaned up on saved-token auth failures)
- Fix IRC gateway sending UTF-8 BOM on first message, breaking CAP negotiation and SASL auth for all clients
- Handle `AUTHENTICATE *` (SASL abort) instead of crashing on invalid base64
- Fix userlist not refreshing when creating a new channel — now fetches online users after channel creation
- Fix unmute timer not working — add `MuteExpirationService` background job that proactively unmutes users when their timed mute expires (previously only checked on message send)
- Fix missing space between mod/admin role icon and username in the userlist
- Fix invisible users still visible in the userlist — `GetOnlineUsersAsync` now filters invisible users; server skips `UserJoined` broadcast for invisible users
- Fix invisible→online transition — user reappears in cached userlist when switching from invisible back to a visible status
- Fix thread safety — `_channelUsers` presence cache now protected by `Lock` to prevent races between SignalR events and background fetches
- Fix `@mention` regex matching email addresses and `#channel` regex matching hex colors / issue numbers — both now use lookbehind and letter-requirement guards
- Fix `ParseThemeColor` accepting non-hex characters — now validates `[0-9a-fA-F]` digits
- Fix ghost channel when trying to join a channel that doesn't exist
## New Features
- Add Docker support for EchoHub.Server — `docker compose up -d` for easy self-hosting with persistent volume for database, uploads, and logs
- IRC account creation — connecting with a new username auto-registers the account (PASS and SASL PLAIN)
- Auto-updater rollback — pre-update backup created automatically before each update; restore via File > Rollback menu or `--rollback` CLI flag
- Update failure recovery — if an update fails mid-extraction, offers to restore from the backup immediately
- Defensive Unix permission check — verify execute permission on startup after auto-update (defense-in-depth)
- Clickable usernames — press Enter on a username in the userlist or message sender to view their profile
- Clickable @mentions — press Enter on a message containing `@username` to open that user's profile
- Clickable #channels — press Enter on a message containing `#channel` to join/switch to that channel; `#channel` references are now highlighted in chat
- Embed theme colors — embed vertical border line now uses the source site's `theme-color` meta tag when available
- Stateful userlist — user presence is cached per channel and updated incrementally via SignalR events instead of re-fetching the full list on every join/leave/status change
## Security
- Restrict file auto-open — only safe file types (video, PDF, text) are opened with the system default app; all other files are downloaded to temp without executing (prevents script execution via `.bat`, `.exe`, etc.)
## Refactoring
- Extract `IUserService`/`UserService` — consolidate user registration, authentication, and profile management into a dedicated service, eliminating duplicated logic between `AuthController` and `ChatService`
- IRC gateway now checks ban status during authentication (previously skipped)
- EchoHubSpace directory updates — server now only sends user count when it actually changes instead of every 30 seconds
- `ChatHub.JoinChannel` now returns `JoinChannelResult` instead of `List<MessageDto>` to allow for better error handling
## Distribution
- Add Chocolatey package — `choco install echohub` for Windows users, auto-published from CI on each release
- Add Linux/macOS install script — `curl -sSfL .../install.sh | sh` with automatic OS/arch detection
## Documentation
- Add Flows section — Mermaid sequence diagrams documenting all major request/event flows (auth, connection, messaging, channels, moderation, file upload, link embeds, server directory) with inline code references
## CI
- Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release
- Add `linux-arm64` builds to release pipeline — server and client binaries for ARM Linux (Raspberry Pi, cloud ARM instances)
- Automate Chocolatey package publishing in release workflow (checksum calculation, pack, push)
+142
View File
@@ -0,0 +1,142 @@
# Authentication
## User Registration
A new user creates an account on a server. The client sends credentials via REST,
the server hashes the password, issues JWT tokens, and the client stores the
refresh token for "Remember Me" sessions.
```mermaid
sequenceDiagram
participant UI as ConnectDialog
participant AO as AppOrchestrator
participant CM as ConnectionManager
participant API as ApiClient
participant Auth as AuthController
participant US as UserService
participant JWT as JwtTokenService
participant DB as SQLite
UI->>AO: ConnectDialogResult(IsRegister: true)
AO->>CM: ConnectAsync(dialogResult)
CM->>API: RegisterAsync(username, password)
API->>Auth: POST /api/auth/register
Auth->>US: RegisterUserAsync(username, password, displayName)
US->>US: Validate (regex, length, uniqueness)
US->>DB: INSERT User (BCrypt hash)
US-->>Auth: UserOperationResult.Success
Auth->>JWT: GenerateAccessToken(user)
JWT-->>Auth: (token, expiresAt) [15 min]
Auth->>JWT: GenerateRefreshToken()
JWT-->>Auth: Base64 random (64 bytes)
Auth->>DB: INSERT RefreshToken (SHA256 hash)
Auth-->>API: LoginResponse
API->>API: SetTokens() — store in memory + set Bearer header
API-->>CM: LoginResponse
CM->>CM: Wire OnTokensRefreshed for config persistence
CM->>CM: Continue to connection setup (see Connection Flow)
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Dialog UI | `src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs` | Lines 251-268 (register handler) |
| Orchestrator entry | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 550-592 (`HandleConnect`) |
| ConnectionManager auth | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 74-76 (register branch) |
| ApiClient register | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 32-43 (`RegisterAsync`) |
| AuthController register | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 28-49 |
| UserService register | `src/EchoHub.Server/Services/UserService.cs` | Lines 20-59 (`RegisterUserAsync`) |
| JWT generation | `src/EchoHub.Server/Auth/JwtTokenService.cs` | Lines 30-53 (access), 80-86 (refresh) |
| Token persistence | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 1039-1053 (`SaveServerToConfig`) |
---
## User Login
Returning user authenticates with username/password or a saved refresh token.
```mermaid
sequenceDiagram
participant UI as ConnectDialog
participant CM as ConnectionManager
participant API as ApiClient
participant Auth as AuthController
participant US as UserService
participant DB as SQLite
alt Saved refresh token (Remember Me)
UI->>CM: ConnectDialogResult(SavedRefreshToken: "...")
CM->>API: LoginWithRefreshTokenAsync()
API->>Auth: POST /api/auth/refresh
Auth->>DB: Lookup token by SHA256 hash
Auth->>DB: Revoke old token, issue new pair
Auth-->>API: LoginResponse (rotated tokens)
else Username + Password
UI->>CM: ConnectDialogResult(IsRegister: false)
CM->>API: LoginAsync(username, password)
API->>Auth: POST /api/auth/login
Auth->>US: AuthenticateUserAsync(username, password)
US->>DB: Fetch user, BCrypt.Verify(password, hash)
US->>DB: Update LastSeenAt
US-->>Auth: UserOperationResult.Success
Auth-->>API: LoginResponse
end
API->>API: SetTokens()
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Login button handler | `src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs` | Lines 214-249 |
| Saved token branch | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 69-71 |
| Password branch | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 78-80 |
| ApiClient login | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 45-56 (`LoginAsync`) |
| AuthController login | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 51-72 |
| AuthController refresh | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 74-108 |
| UserService authenticate | `src/EchoHub.Server/Services/UserService.cs` | Lines 61-83 |
---
## Token Refresh
Access tokens expire after 15 minutes. The client auto-refreshes transparently
before requests and on 401 responses. Refresh tokens are rotated on each use.
```mermaid
sequenceDiagram
participant SR as SignalR / HTTP Request
participant API as ApiClient
participant Auth as AuthController
participant DB as SQLite
participant Config as config.json
SR->>API: GetValidTokenAsync() or HTTP 401
API->>API: Token expires within 60s?
alt Proactive refresh (SignalR token provider)
API->>Auth: POST /api/auth/refresh (old refresh token)
else Reactive refresh (HTTP 401 retry)
API->>Auth: POST /api/auth/refresh (old refresh token)
end
Auth->>DB: Lookup by SHA256 hash
Auth->>DB: Revoke old refresh token
Auth->>DB: INSERT new RefreshToken
Auth-->>API: LoginResponse (new token pair)
API->>API: SetTokens() — update Bearer header
API-->>API: Fire OnTokensRefreshed event
API-->>Config: Persist new refresh token (if Remember Me)
API->>SR: Retry original request with new token
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Proactive check | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 110-129 (`GetValidTokenAsync`) |
| Reactive 401 retry (GET) | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 338-358 (`AuthenticatedGetAsync`) |
| Reactive 401 retry (POST/PUT/DELETE) | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 364-384 (`AuthenticatedRequestAsync`) |
| Refresh HTTP call | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 58-71 (`RefreshTokenAsync`) |
| SignalR token provider | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Line 37 (`AccessTokenProvider`) |
| Server-side rotation | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 74-108 |
| Token persistence callback | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 253-264 |
+159
View File
@@ -0,0 +1,159 @@
# Channels
## Channel Creation
Channels are created via the REST API. Public channels are broadcast to all
connected clients.
```mermaid
sequenceDiagram
participant Client as Client (TUI/API)
participant CC as ChannelsController
participant ChS as ChannelService
participant CS as ChatService
participant DB as SQLite
participant SRB as SignalRBroadcaster
participant IRCB as IrcBroadcaster
Client->>CC: POST /api/channels {name, topic, isPublic}
CC->>ChS: CreateChannelAsync(creatorId, name, topic, isPublic)
ChS->>ChS: Normalize name (lowercase, trim)
ChS->>ChS: Validate format (2-100 chars, regex)
ChS->>DB: Check duplicate
ChS->>DB: INSERT Channel + ChannelMembership (creator auto-added)
ChS-->>CC: ChannelDto
alt Channel is public
CC->>CS: BroadcastChannelUpdatedAsync(channel)
CS->>SRB: SendChannelUpdatedAsync(channelDto)
SRB->>SRB: Notify all SignalR clients
CS->>IRCB: SendChannelUpdatedAsync(channelDto)
end
CC-->>Client: 201 Created (ChannelDto)
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Controller endpoint | `src/EchoHub.Server/Controllers/ChannelsController.cs` | Lines 60-76 |
| Channel service create | `src/EchoHub.Server/Services/ChannelService.cs` | Lines 50-90 |
| Broadcast updated | `src/EchoHub.Server/Services/ChatService.cs` | Lines 308-309 |
---
## Channel Deletion
Only the channel creator (or admin) can delete a channel. The default channel
is protected.
```mermaid
sequenceDiagram
participant Client as Client
participant CC as ChannelsController
participant ChS as ChannelService
participant DB as SQLite
Client->>CC: DELETE /api/channels/{channel}
CC->>ChS: DeleteChannelAsync(channelName, callerId)
ChS->>ChS: Reject if default channel
ChS->>DB: Lookup channel
ChS->>ChS: Verify caller is creator or admin
ChS->>DB: DELETE Channel (cascade: messages, memberships)
ChS-->>CC: Success
CC-->>Client: 204 No Content
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Controller endpoint | `src/EchoHub.Server/Controllers/ChannelsController.cs` | Lines 94-106 |
| Channel service delete | `src/EchoHub.Server/Services/ChannelService.cs` | Lines 119-144 |
---
## Joining a Channel
Both SignalR and IRC clients join channels through `ChatService`. The presence
tracker determines if this is a genuinely new join (vs. a second connection) and
broadcasts accordingly.
```mermaid
sequenceDiagram
participant Client as Client (SignalR or IRC)
participant Entry as ChatHub / IrcCommandHandler
participant CS as ChatService
participant ChS as ChannelService
participant PT as PresenceTracker
participant DB as SQLite
participant SRB as SignalRBroadcaster
participant IRCB as IrcBroadcaster
Client->>Entry: JoinChannel / JOIN #channel
Entry->>CS: JoinChannelAsync(connectionId, userId, username, channel)
CS->>ChS: EnsureChannelMembershipAsync(userId, channel)
ChS->>DB: INSERT ChannelMembership (if not exists)
CS->>PT: JoinChannel(username, channel)
PT-->>CS: isNewJoin?
alt First connection in this channel
CS->>DB: Fetch UserPresenceDto
CS->>CS: BroadcastToAllAsync(SendUserJoinedAsync)
par
CS->>SRB: SendUserJoinedAsync(channel, user, excludeConn)
and
CS->>IRCB: SendUserJoinedAsync(channel, user)
end
end
CS->>DB: Fetch message history
CS-->>Entry: (history, error)
Entry-->>Client: History messages
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| SignalR hub join | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 59-81 |
| IRC join | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 361-414 |
| ChatService join | `src/EchoHub.Server/Services/ChatService.cs` | Lines 96-135 |
| Presence join | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 58-70 |
| SignalR broadcast | `src/EchoHub.Server/Services/SignalRBroadcaster.cs` | Lines 26-32 |
| IRC broadcast | `src/EchoHub.Server.Irc/IrcBroadcaster.cs` | Lines 34-41 |
---
## Leaving a Channel
```mermaid
sequenceDiagram
participant Client as Client
participant Entry as ChatHub / IrcCommandHandler
participant CS as ChatService
participant PT as PresenceTracker
participant SRB as SignalRBroadcaster
participant IRCB as IrcBroadcaster
Client->>Entry: LeaveChannel / PART #channel
Entry->>CS: LeaveChannelAsync(connectionId, username, channel)
CS->>PT: LeaveChannel(username, channel)
CS->>CS: BroadcastToAllAsync(SendUserLeftAsync)
par
CS->>SRB: SendUserLeftAsync(channel, username)
and
CS->>IRCB: SendUserLeftAsync(channel, username)
end
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| SignalR hub leave | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 83-96 |
| IRC part | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 416-435 |
| ChatService leave | `src/EchoHub.Server/Services/ChatService.cs` | Lines 137-143 |
| Presence leave | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 72-81 |
+150
View File
@@ -0,0 +1,150 @@
# Connection
## SignalR Client Connection
After authentication, the TUI client establishes a SignalR WebSocket, registers
event handlers, joins the default channel, and loads history.
```mermaid
sequenceDiagram
participant CM as ConnectionManager
participant EHC as EchoHubConnection
participant Hub as ChatHub
participant CS as ChatService
participant PT as PresenceTracker
participant DB as SQLite
CM->>CM: Fetch encryption key (GET /api/server/encryption-key)
CM->>EHC: new EchoHubConnection(apiClient, encryption)
EHC->>EHC: Build HubConnection (URL + JWT token provider + auto-reconnect)
EHC->>EHC: RegisterHandlers() — wire ReceiveMessage, UserJoined, etc.
EHC->>Hub: ConnectAsync() → WebSocket handshake
Hub->>CS: UserConnectedAsync(connectionId, userId, username)
CS->>PT: UserConnected(connectionId, userId, username)
CS->>DB: Update user: Status=Online, LastSeenAt=now
CM->>CM: Fetch channel list (GET /api/channels)
CM->>EHC: JoinChannelAsync("general")
EHC->>Hub: InvokeAsync("JoinChannel", "general")
Hub->>CS: JoinChannelAsync(connectionId, userId, username, "general")
CS->>DB: EnsureChannelMembership
CS->>PT: JoinChannel(username, "general")
CS->>CS: BroadcastToAllAsync → UserJoined
CS->>DB: Fetch message history
Hub-->>EHC: List<MessageDto> (encrypted)
EHC-->>CM: Decrypted history
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Connection orchestration | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 58-140 (`ConnectAsync`) |
| EchoHubConnection setup | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 29-62 (constructor) |
| Handler registration | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 64-122 (`RegisterHandlers`) |
| Hub OnConnected | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 31-43 |
| ChatService connected | `src/EchoHub.Server/Services/ChatService.cs` | Lines 41-57 |
| PresenceTracker connect | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 13-29 |
| Join channel (hub) | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 59-81 |
| Join channel (service) | `src/EchoHub.Server/Services/ChatService.cs` | Lines 96-135 |
---
## IRC Client Connection
IRC clients connect via TCP, authenticate with PASS/NICK/USER or SASL PLAIN,
and auto-join channels. New usernames are auto-registered.
```mermaid
sequenceDiagram
participant IRC as IRC Client
participant GW as IrcGatewayService
participant CH as IrcCommandHandler
participant US as UserService
participant CS as ChatService
participant PT as PresenceTracker
IRC->>GW: TCP connect (:6667 or :6697 TLS)
GW->>GW: Accept + create IrcClientConnection
GW->>CH: new IrcCommandHandler(connection, services)
GW->>CH: RunAsync() — start read loop
alt SASL PLAIN
IRC->>CH: CAP REQ :sasl
CH-->>IRC: CAP ACK :sasl
IRC->>CH: AUTHENTICATE PLAIN
CH-->>IRC: AUTHENTICATE +
IRC->>CH: AUTHENTICATE <base64(\0user\0pass)>
CH->>US: AuthenticateUserAsync(user, pass)
alt Auth fails → auto-register
CH->>US: RegisterUserAsync(user, pass)
end
CH-->>IRC: 903 :SASL authentication successful
else PASS/NICK/USER
IRC->>CH: PASS <password>
IRC->>CH: NICK <nickname>
IRC->>CH: USER <username> 0 * :<realname>
CH->>US: AuthenticateUserAsync(nick, pass)
alt Auth fails → auto-register
CH->>US: RegisterUserAsync(nick, pass)
end
end
CH->>CS: UserConnectedAsync(irc-{guid}, userId, username)
CS->>PT: UserConnected(irc-{guid}, userId, username)
CH-->>IRC: 001-004 RPL_WELCOME burst + MOTD
Note over IRC,CH: Client is now ready for JOIN/PART/PRIVMSG
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| TCP listener | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 45-90 (`ExecuteAsync`) |
| Client handler | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 92-154 (`HandleClientAsync`) |
| Command read loop | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 40-98 (`RunAsync`) |
| SASL auth | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 136-207 (`HandleAuthenticateAsync`) |
| PASS/NICK/USER | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 209-267 |
| Registration completion | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 268-315 (`TryCompleteRegistrationAsync`) |
| Cleanup on disconnect | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 136-153 |
---
## User Disconnect & Presence
When a client disconnects, the presence tracker determines if the user has any
remaining connections. If not, status is set to Invisible and all channels are
notified.
```mermaid
sequenceDiagram
participant Client as Client
participant Entry as ChatHub / IrcGatewayService
participant CS as ChatService
participant PT as PresenceTracker
participant DB as SQLite
participant SRB as SignalRBroadcaster
Client->>Entry: Disconnect / TCP close
Entry->>CS: UserDisconnectedAsync(connectionId)
CS->>PT: Get username + channels (before removal)
CS->>PT: UserDisconnected(connectionId)
PT->>PT: Remove connection from tracking
alt No remaining connections for user
CS->>DB: Update user: Status=Invisible, LastSeenAt=now
loop For each channel user was in
CS->>CS: BroadcastToAllAsync(SendUserStatusChangedAsync)
CS->>SRB: Notify channel members
end
end
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| SignalR disconnect | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 45-57 |
| IRC cleanup | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 136-153 |
| ChatService disconnect | `src/EchoHub.Server/Services/ChatService.cs` | Lines 59-94 |
| Presence disconnect | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 31-53 |
+3
View File
@@ -0,0 +1,3 @@
# Flows
This section documents the major request/event flows in EchoHub, showing how data moves between the TUI client, IRC client, server, and database. Each diagram includes code references so you can jump straight to the implementation.
+118
View File
@@ -0,0 +1,118 @@
# Media & Services
## File Upload
Files are uploaded via REST, validated by magic bytes, stored with GUID filenames,
and broadcast as a message with a download link.
```mermaid
sequenceDiagram
participant Client as Client
participant CC as ChannelsController
participant FV as FileValidationHelper
participant FS as FileStorageService
participant CS as ChatService
participant DB as SQLite
Client->>CC: POST /api/channels/{channel}/upload (multipart)
CC->>CC: Check file size limits
CC->>FV: IsValidImage(stream) — magic byte check
FV->>FV: Read header: JPEG(FFD8FF) / PNG(89504E47) / GIF / WebP(RIFF+WEBP)
FV-->>CC: true/false
CC->>FS: SaveFileAsync(stream, extension)
FS->>FS: Generate GUID filename, write to uploads/
FS-->>CC: fileId (GUID)
CC->>CC: Determine MessageType (Image/Audio/File)
CC->>CS: SendMessageAsync (with file URL + optional ASCII art)
CS->>DB: INSERT Message
CS->>CS: BroadcastToAllAsync → fan out to clients
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Upload endpoint | `src/EchoHub.Server/Controllers/ChannelsController.cs` | Lines 108-200 |
| File validation | `src/EchoHub.Server/Services/FileValidationHelper.cs` | Lines 15-82 |
| File storage | `src/EchoHub.Server/Services/FileStorageService.cs` | Lines 1-47 |
| File download | `src/EchoHub.Server/Controllers/FilesController.cs` | Lines 22-53 |
---
## Link Embed Resolution
When a message contains URLs, the server fetches OpenGraph metadata for preview
embeds.
```mermaid
sequenceDiagram
participant CS as ChatService
participant LE as LinkEmbedService
participant Web as External Website
CS->>LE: TryGetEmbedsAsync(messageContent)
LE->>LE: Extract URLs via regex
LE->>LE: Filter: max URLs per message, skip duplicates
loop For each URL
LE->>LE: Validate: http(s) only, block private IPs
LE->>Web: GET URL (timeout + size limit)
Web-->>LE: HTML response
LE->>LE: Parse og:title, og:description, og:site_name
LE->>LE: Parse theme-color meta tag
LE->>LE: Fallback to <title> if no og:title
end
LE-->>CS: List<EmbedDto> (or null)
Note over CS: Attached to MessageDto before broadcast
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Entry point | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 28-51 (`TryGetEmbedsAsync`) |
| URL extraction | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 145-160 |
| Private IP blocking | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 162-181 |
| OG tag parsing | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 187-210 |
| Theme color parsing | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 117-143 |
| ChatService integration | `src/EchoHub.Server/Services/ChatService.cs` | Lines 194-201 |
---
## Server Directory Registration
Public servers register with the EchoHubSpace directory for discoverability.
```mermaid
sequenceDiagram
participant SDS as ServerDirectoryService
participant Dir as EchoHubSpace Directory
participant PT as PresenceTracker
SDS->>SDS: Check Server:PublicServer config
SDS->>Dir: SignalR connect (echohub.voidcube.cloud/hubs/servers)
SDS->>Dir: RegisterServer(name, description, host, userCount)
Dir-->>SDS: Registered
loop Every 30 seconds
SDS->>PT: Get online user count
alt Count changed
SDS->>Dir: UpdateUserCount(count)
end
end
Dir->>SDS: Ping
SDS->>Dir: Heartbeat
Note over SDS,Dir: Exponential backoff on disconnect (2s → 30s max)
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Service lifecycle | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 29-122 |
| Registration | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 195-212 |
| User count polling | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 154-187 |
| Reconnection backoff | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 77-82, 191 |
+210
View File
@@ -0,0 +1,210 @@
# Messaging
## Sending a Message (SignalR)
A message typed in the TUI travels through encryption, the SignalR hub,
`ChatService` validation, database storage, and fan-out to both SignalR and IRC
clients.
```mermaid
sequenceDiagram
participant UI as MainWindow (TUI)
participant AO as AppOrchestrator
participant EHC as EchoHubConnection
participant Hub as ChatHub
participant CS as ChatService
participant LE as LinkEmbedService
participant DB as SQLite
participant SRB as SignalRBroadcaster
participant IRCB as IrcBroadcaster
participant Clients as Other Clients
UI->>AO: OnMessageSubmitted(channel, text)
AO->>AO: IsCommand(text)? → No
AO->>EHC: SendMessageAsync(channel, text)
EHC->>EHC: Encrypt(text) → ciphertext
EHC->>Hub: InvokeAsync("SendMessage", channel, ciphertext)
Hub->>CS: SendMessageAsync(userId, username, channel, ciphertext)
CS->>CS: Decrypt(ciphertext) → plaintext
CS->>CS: Validate (length, newlines, channel exists)
CS->>DB: Check mute status
CS->>LE: TryGetEmbedsAsync(plaintext)
LE->>LE: Extract URLs, fetch OG tags
LE-->>CS: List<EmbedDto> (or null)
CS->>DB: INSERT Message (encrypted at rest)
CS->>CS: Re-encrypt plaintext for transport
CS->>CS: Build MessageDto with embeds
par Fan-out to all broadcasters
CS->>SRB: SendMessageToChannelAsync(channel, dto)
SRB->>Clients: HubContext.Group(channel).ReceiveMessage(dto)
and
CS->>IRCB: SendMessageToChannelAsync(channel, dto)
IRCB->>IRCB: Decrypt → format as PRIVMSG lines
IRCB->>Clients: Send to each IRC conn (skip sender)
end
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Input handler | `src/EchoHub.Client/UI/MainWindow.cs` | Lines 428-449 (`OnInputKeyDown`) |
| Orchestrator dispatch | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 631-661 (`HandleMessageSubmitted`) |
| Client encrypt + send | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 148-153 (`SendMessageAsync`) |
| Hub receive | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 98-111 (`SendMessage`) |
| ChatService process | `src/EchoHub.Server/Services/ChatService.cs` | Lines 145-241 (`SendMessageAsync`) |
| Mute check | `src/EchoHub.Server/Services/ChatService.cs` | Lines 177-190 |
| Link embeds | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 28-51 (`TryGetEmbedsAsync`) |
| DB insert | `src/EchoHub.Server/Services/ChatService.cs` | Lines 208-221 |
| Broadcast fan-out | `src/EchoHub.Server/Services/ChatService.cs` | Lines 311-324 (`BroadcastToAllAsync`) |
| SignalR broadcast | `src/EchoHub.Server/Services/SignalRBroadcaster.cs` | Lines 23-24 |
| IRC broadcast | `src/EchoHub.Server.Irc/IrcBroadcaster.cs` | Lines 17-32 |
---
## Sending a Message (IRC)
Messages from IRC clients follow the same `ChatService` path but enter as
plaintext (no app-layer encryption).
```mermaid
sequenceDiagram
participant IRC as IRC Client
participant CH as IrcCommandHandler
participant CS as ChatService
participant DB as SQLite
participant SRB as SignalRBroadcaster
participant IRCB as IrcBroadcaster
IRC->>CH: PRIVMSG #channel :Hello world
CH->>CH: Parse target + content
CH->>CH: IrcToEchoHubChannel("#channel") → "channel"
CH->>CS: SendMessageAsync(userId, username, "channel", "Hello world")
CS->>CS: Decrypt("Hello world") → passthrough (no $ENC$ prefix)
CS->>CS: Validate, check mute, fetch embeds
CS->>DB: INSERT Message
CS->>CS: Encrypt plaintext for SignalR transport
par Fan-out
CS->>SRB: SendMessageToChannelAsync (encrypted for SignalR)
and
CS->>IRCB: SendMessageToChannelAsync (decrypt → PRIVMSG)
IRCB->>IRCB: Skip sender (IRC echo suppression)
end
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| PRIVMSG handler | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 437-469 |
| Channel name conversion | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Line 680 (`IrcToEchoHubChannel`) |
| ChatService (shared path) | `src/EchoHub.Server/Services/ChatService.cs` | Lines 145-241 |
| IRC echo suppression | `src/EchoHub.Server.Irc/IrcBroadcaster.cs` | Lines 25-26 |
---
## Receiving a Message (TUI Client)
When a message arrives via SignalR, the client decrypts it, adds it to the chat
view, and optionally plays a notification sound for @mentions.
```mermaid
sequenceDiagram
participant SRB as SignalRBroadcaster
participant EHC as EchoHubConnection
participant AO as AppOrchestrator
participant MM as ChatMessageManager
participant UI as MainWindow
SRB->>EHC: ReceiveMessage(MessageDto)
EHC->>EHC: Decrypt(message.Content)
EHC-->>AO: OnMessageReceived(decrypted dto)
AO->>AO: InvokeUI (thread-safe)
AO->>MM: AddMessage(message)
MM->>UI: Render in chat ListView
alt Message contains @username
AO->>AO: PlayAsync() notification sound
end
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| SignalR handler | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 64-71 |
| Orchestrator receive | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 372-383 |
| @mention detection | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 378-382 |
---
## Command Execution
Slash commands (`/status`, `/nick`, `/kick`, etc.) are parsed client-side and
dispatched to appropriate handlers, which call REST APIs or SignalR methods.
```mermaid
sequenceDiagram
participant UI as MainWindow
participant AO as AppOrchestrator
participant CMD as CommandHandler
participant API as ApiClient
participant EHC as EchoHubConnection
participant Server as Server (API/Hub)
UI->>AO: OnMessageSubmitted(channel, "/kick baduser")
AO->>CMD: IsCommand("/kick baduser")? → true
AO->>CMD: HandleAsync("/kick baduser")
CMD->>CMD: Parse → command="kick", args="baduser"
alt API command (kick, ban, mute, nick, etc.)
CMD-->>AO: Fire OnKickRequested("baduser")
AO->>API: KickUserAsync("baduser")
API->>Server: POST /api/moderation/kick/baduser
else Hub command (status, join, leave, etc.)
CMD-->>AO: Fire OnSetStatus / OnJoinChannel / etc.
AO->>EHC: UpdateStatusAsync() / JoinChannelAsync() / etc.
EHC->>Server: SignalR Invoke
else Local command (theme, help, quit)
CMD-->>AO: Fire OnThemeChanged / etc.
AO->>UI: Apply locally (no server call)
end
AO->>UI: AddSystemMessage(result)
```
**Available commands:**
| Command | Type | Handler |
|---------|------|---------|
| `/status <status> [message]` | Hub | `UpdateStatusAsync` |
| `/nick <name>` | API | `UpdateProfileAsync` |
| `/color <hex>` | API | `UpdateProfileAsync` |
| `/join <channel>` | Hub | `JoinChannelAsync` |
| `/leave` | Hub | `LeaveChannelAsync` |
| `/topic <text>` | API | `UpdateChannelTopicAsync` |
| `/kick <user>` | API | `POST /api/moderation/kick/{user}` |
| `/ban <user>` | API | `POST /api/moderation/ban/{user}` |
| `/unban <user>` | API | `POST /api/moderation/unban/{user}` |
| `/mute <user> [mins]` | API | `POST /api/moderation/mute/{user}` |
| `/unmute <user>` | API | `POST /api/moderation/unmute/{user}` |
| `/role <user> <role>` | API | `PUT /api/moderation/role/{user}` |
| `/nuke` | API | `DELETE /api/channels/{channel}/messages` |
| `/send <file>` | API | `POST /api/channels/{channel}/upload` |
| `/profile [user]` | Local | Show profile dialog |
| `/avatar` | API | `POST /api/users/avatar` |
| `/theme <name>` | Local | `ThemeManager.SetTheme()` |
| `/servers` | API | `GET /api/serverdir/servers` |
| `/users` | Local | Show userlist |
| `/help` | Local | Show help text |
| `/quit` | Local | Exit application |
**Code references:**
| Step | File | Location |
|------|------|----------|
| Command detection | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 639-655 |
| Command dispatch | `src/EchoHub.Client/Commands/CommandHandler.cs` | Lines 34-69 (`HandleAsync`) |
| Command handlers wired | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 97-117 |
| Individual handlers | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 122-350 |
+50
View File
@@ -0,0 +1,50 @@
# Moderation
## Kick / Ban / Mute
Moderators and admins can kick, ban, or mute users via REST API or slash commands.
These actions force-disconnect the target and broadcast the event.
```mermaid
sequenceDiagram
participant Mod as Moderator
participant MC as ModerationController
participant CS as ChatService
participant PT as PresenceTracker
participant DB as SQLite
participant SRB as SignalRBroadcaster
participant IRCB as IrcBroadcaster
Mod->>MC: POST /api/moderation/kick/{user}
alt Kick
MC->>CS: Get user's channels
MC->>CS: BroadcastToAllAsync(SendUserKickedAsync)
MC->>CS: ForceDisconnectAndCleanupAsync(user)
else Ban
MC->>DB: Set user.IsBanned = true
MC->>CS: BroadcastToAllAsync(SendUserBannedAsync)
MC->>CS: ForceDisconnectAndCleanupAsync(user)
else Mute
MC->>DB: Set user.IsMuted = true, MutedUntil = now + duration
Note over DB: MuteExpirationService auto-unmutes when timer expires
end
CS->>PT: ForceRemoveUser(username)
PT->>PT: Remove from all connections + channels
CS->>SRB: ForceDisconnectUserAsync(connectionIds, reason)
CS->>IRCB: ForceDisconnectUserAsync(connectionIds, reason)
CS->>DB: Set Status=Invisible, LastSeenAt=now
```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Kick endpoint | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 62-87 |
| Ban endpoint | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 89-112 |
| Mute endpoint | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 130-151 |
| Force disconnect | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 232-256 |
| Mute expiration | `src/EchoHub.Server/Services/MuteExpirationService.cs` | Lines 22-62 |
| Mute enforcement | `src/EchoHub.Server/Services/ChatService.cs` | Lines 177-190 |
| Presence force remove | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 161-178 |
+12
View File
@@ -0,0 +1,12 @@
- name: Authentication
href: authentication.md
- name: Connection
href: connection.md
- name: Messaging
href: messaging.md
- name: Channels
href: channels.md
- name: Moderation
href: moderation.md
- name: Media & Services
href: media.md
+3
View File
@@ -1,6 +1,9 @@
- name: Articles
href: articles/
homepage: articles/getting-started.md
- name: Flows
href: flows/
homepage: flows/flows.md
- name: Changelog
href: changelog/
homepage: changelog/index.md
+24
View File
@@ -0,0 +1,24 @@
# TODO
- [ ] fix the chat trailing; when user scrolls up, and somebody sends a message the chat instantly "teleports" to the very bottom
- [x] disable the autorun of files (maybe keep for mp4? gotta do some sec research on it)
- [x] when user creates a new channel, he gets moved to that channel; but the userlist does not refresh the state on that it refreshes when user re-enters the channel again
- [ ] password protected rooms
- [ ] better audio lib, current one (NetCoreAudio) does not support seek or other audio actions
- [ ] Use options pattern for both client & server
- ref: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-10.0
- [x] The vertical line of embeds should be the same colour as theme-color meta tag of the source
- [x] change text wrapping to honor the offset of the user. (Preferably wrap whole words if they fit in on line. Basically how the CSS "text-wrap-mode: wrap;" works)
- [x] make usernames in messages, mentions and in the user list clickable to open the user profile
- [ ] Add tags support for public servers & add filters on echohubspace for those tags in the server "browser"
- [ ] when trying to /join a channel that doesn't exist a client side-ghost channel gets created that does not work
- [x] unmute timer does not seem to work
- [ ] add IRC network like support
- that means basically multiple servers linked, so users can chat cross-server in this network
- [ ] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch
- [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down)
- [ ] add search bar / search modal that will allow users to instantly navigate to room / focus on app element & etc
- [ ] Actually smart data management cache messages, lazy load messages on scroll (currently hardcoded 100msgs fetched + new ones)
- [x] Another thing would be stateful userlist basically fetch once and listen for userlist updates
- [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update
- [x] space between mod|admin "icon" and username
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>echohub</id>
<version>__VERSION__</version>
<title>EchoHub</title>
<authors>HueByte</authors>
<owners>HueByte</owners>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<licenseUrl>https://github.com/HueByteByte/EchoHub/blob/master/LICENSE</licenseUrl>
<projectUrl>https://github.com/HueByteByte/EchoHub</projectUrl>
<projectSourceUrl>https://github.com/HueByteByte/EchoHub</projectSourceUrl>
<docsUrl>https://HueBytebyte.github.io/EchoHub</docsUrl>
<bugTrackerUrl>https://github.com/HueByteByte/EchoHub/issues</bugTrackerUrl>
<packageSourceUrl>https://github.com/HueByteByte/EchoHub/tree/master/packaging/choco</packageSourceUrl>
<iconUrl>https://raw.githubusercontent.com/HueByteByte/EchoHub/master/assets/HueByte_icon.png</iconUrl>
<description>EchoHub is a decentralized IRC-style chat application with a terminal user interface (TUI). Connect to any EchoHub server, join channels, and chat — all from your terminal. Features include SignalR real-time messaging, file sharing, custom themes, and IRC gateway compatibility.</description>
<summary>Decentralized terminal chat client with IRC gateway support</summary>
<tags>chat irc decentralized tui terminal signalr echohub</tags>
<releaseNotes>https://HueBytebyte.github.io/EchoHub/changelog/v__VERSION__.html</releaseNotes>
<copyright>Copyright (c) 2026 HueByte</copyright>
</metadata>
<files>
<file src="tools\**" target="tools" />
</files>
</package>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Hue
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
VERIFICATION
To verify the package contents:
1. Download the official release from:
https://github.com/HueByte/EchoHub/releases
2. Download: EchoHub-Client-win-x64.zip
3. Calculate the SHA256 checksum:
- PowerShell: Get-FileHash EchoHub-Client-win-x64.zip -Algorithm SHA256
- Linux/macOS: sha256sum EchoHub-Client-win-x64.zip
4. Compare with the checksum in chocolateyInstall.ps1 (checksum64 value).
LICENSE: MIT License - see LICENSE.txt in this directory or
https://github.com/HueByte/EchoHub/blob/master/LICENSE
@@ -0,0 +1,18 @@
$ErrorActionPreference = 'Stop'
$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$version = $env:chocolateyPackageVersion
$packageArgs = @{
packageName = $env:chocolateyPackageName
unzipLocation = $toolsDir
url64bit = "https://github.com/HueByte/EchoHub/releases/download/v$version/EchoHub-Client-win-x64.zip"
checksum64 = '__CHECKSUM64__'
checksumType64 = 'sha256'
}
Install-ChocolateyZipPackage @packageArgs
# Create a shim so 'echohub' is available on PATH
$exePath = Join-Path $toolsDir 'client-win-x64' 'EchoHub.Client.exe'
Install-BinFile -Name 'echohub' -Path $exePath
@@ -0,0 +1,3 @@
$ErrorActionPreference = 'Stop'
Uninstall-BinFile -Name 'echohub'
+223
View File
@@ -0,0 +1,223 @@
#!/bin/sh
# EchoHub Client Installer
# Usage: curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/install.sh | sh
#
# Options (pass as arguments or environment variables):
# --version X.Y.Z Install a specific version (default: latest)
# --install-dir DIR Install to a custom directory
# --help Show this help message
set -eu
REPO="HueByte/EchoHub"
BINARY_NAME="echohub"
INSTALL_DIR=""
VERSION=""
# ── Argument parsing ──────────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
--version)
VERSION="$2"
shift 2
;;
--install-dir)
INSTALL_DIR="$2"
shift 2
;;
--help)
sed -n '2,8p' "$0" 2>/dev/null || true
echo ""
echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh"
echo " curl ... | sh -s -- --version 0.2.8"
echo " curl ... | sh -s -- --install-dir /opt/echohub"
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
# ── Platform detection ────────────────────────────────────────────────────
detect_os() {
case "$(uname -s)" in
Linux*) echo "linux" ;;
Darwin*) echo "osx" ;;
*)
echo "Error: Unsupported operating system: $(uname -s)" >&2
echo "EchoHub supports Linux and macOS. For Windows, use: choco install echohub" >&2
exit 1
;;
esac
}
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "x64" ;;
aarch64|arm64) echo "arm64" ;;
*)
echo "Error: Unsupported architecture: $(uname -m)" >&2
echo "EchoHub supports x64 and arm64." >&2
exit 1
;;
esac
}
# ── Version resolution ────────────────────────────────────────────────────
resolve_version() {
if [ -n "$VERSION" ]; then
echo "$VERSION"
return
fi
# Fetch latest release tag from GitHub API
if command -v curl >/dev/null 2>&1; then
tag=$(curl -sSf "https://api.github.com/repos/$REPO/releases/latest" \
| grep '"tag_name"' | head -1 | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\?\([^"]*\)".*/\1/')
elif command -v wget >/dev/null 2>&1; then
tag=$(wget -qO- "https://api.github.com/repos/$REPO/releases/latest" \
| grep '"tag_name"' | head -1 | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\?\([^"]*\)".*/\1/')
else
echo "Error: curl or wget is required to detect the latest version." >&2
echo "Install curl/wget or specify a version with --version X.Y.Z" >&2
exit 1
fi
if [ -z "$tag" ]; then
echo "Error: Could not determine latest version from GitHub." >&2
exit 1
fi
echo "$tag"
}
# ── Install directory resolution ──────────────────────────────────────────
resolve_install_dir() {
if [ -n "$INSTALL_DIR" ]; then
echo "$INSTALL_DIR"
return
fi
# Prefer /usr/local/bin if writable, otherwise ~/.local/bin
if [ -w "/usr/local/bin" ]; then
echo "/usr/local/bin"
else
local_bin="$HOME/.local/bin"
mkdir -p "$local_bin"
echo "$local_bin"
fi
}
# ── Download helper ───────────────────────────────────────────────────────
download() {
url="$1"
output="$2"
if command -v curl >/dev/null 2>&1; then
curl -sSfL "$url" -o "$output"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$output" "$url"
else
echo "Error: curl or wget is required." >&2
exit 1
fi
}
# ── PATH setup ────────────────────────────────────────────────────────────
add_to_path() {
dir="$1"
export_line="export PATH=\"${dir}:\$PATH\" # Added by EchoHub"
added=0
for profile in "$HOME/.profile" "$HOME/.bashrc" "$HOME/.zshrc"; do
if [ -f "$profile" ]; then
if grep -q "$dir" "$profile" 2>/dev/null; then
continue # Already present
fi
printf '\n%s\n' "$export_line" >> "$profile"
echo " Added to PATH in $(basename "$profile")"
added=1
fi
done
# If no profile existed, create .profile
if [ "$added" -eq 0 ]; then
printf '\n%s\n' "$export_line" >> "$HOME/.profile"
echo " Added to PATH in .profile"
fi
}
# ── Main ──────────────────────────────────────────────────────────────────
main() {
os=$(detect_os)
arch=$(detect_arch)
version=$(resolve_version)
install_dir=$(resolve_install_dir)
artifact="EchoHub-Client-${os}-${arch}.zip"
url="https://github.com/$REPO/releases/download/v${version}/${artifact}"
echo "EchoHub Installer"
echo " Version: v${version}"
echo " Platform: ${os}-${arch}"
echo " Install to: ${install_dir}"
echo ""
# Create temp directory with cleanup trap
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
echo "Downloading ${artifact}..."
download "$url" "$tmpdir/echohub.zip"
echo "Extracting..."
if command -v unzip >/dev/null 2>&1; then
unzip -qo "$tmpdir/echohub.zip" -d "$tmpdir/extract"
else
echo "Error: unzip is required to extract the archive." >&2
echo "Install it with: apt install unzip / brew install unzip" >&2
exit 1
fi
# The ZIP contains a client-{os}-{arch}/ subdirectory
src_dir="$tmpdir/extract/client-${os}-${arch}"
if [ ! -d "$src_dir" ]; then
# Fallback: look for any directory containing the binary
src_dir=$(find "$tmpdir/extract" -name "EchoHub.Client" -type f -printf '%h' -quit 2>/dev/null || true)
if [ -z "$src_dir" ]; then
echo "Error: Could not find EchoHub.Client binary in the archive." >&2
exit 1
fi
fi
# Install the binary
mkdir -p "$install_dir"
cp "$src_dir/EchoHub.Client" "$install_dir/$BINARY_NAME"
chmod +x "$install_dir/$BINARY_NAME"
echo ""
# Ensure install directory is on PATH
if ! command -v "$BINARY_NAME" >/dev/null 2>&1; then
add_to_path "$install_dir"
fi
echo "Installed successfully! Run 'echohub' to start."
echo ""
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$install_dir"; then
echo "NOTE: Restart your shell or run the following to use echohub now:"
echo ""
echo " export PATH=\"${install_dir}:\$PATH\""
fi
}
main
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.2.6</Version>
<Version>0.2.8</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
+174 -11
View File
@@ -28,6 +28,8 @@ public sealed class AppOrchestrator : IDisposable
private readonly AudioPlaybackService _audioPlayback = new();
private readonly UpdateChecker _updateService;
private readonly ConnectionManager _conn = new();
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _channelUsersLock = new();
private ClientConfig _config;
private readonly UserSession _session = new();
@@ -85,6 +87,10 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
_mainWindow.OnUserProfileRequested += HandleViewProfile;
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
}
// ── Command Handler Wiring ─────────────────────────────────────────────
@@ -379,17 +385,48 @@ public sealed class AppOrchestrator : IDisposable
}
};
_conn.UserJoined += (channelName, username) =>
_conn.UserJoined += (channelName, username, presence) =>
{
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} joined the channel"));
if (channelName == _mainWindow.CurrentChannel)
List<UserPresenceDto>? snapshot = null;
lock (_channelUsersLock)
{
if (presence is not null && _channelUsers.TryGetValue(channelName, out var users))
{
if (!users.Any(u => u.Username.Equals(presence.Username, StringComparison.OrdinalIgnoreCase)))
users.Add(presence);
if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
snapshot = [.. users];
}
}
if (snapshot is not null)
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
else if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
FetchAndUpdateOnlineUsers();
};
_conn.UserLeft += (channelName, username) =>
{
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} left the channel"));
if (channelName == _mainWindow.CurrentChannel)
List<UserPresenceDto>? snapshot = null;
lock (_channelUsersLock)
{
if (_channelUsers.TryGetValue(channelName, out var users))
{
users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
snapshot = [.. users];
}
}
if (snapshot is not null)
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
else if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
FetchAndUpdateOnlineUsers();
};
@@ -405,18 +442,75 @@ public sealed class AppOrchestrator : IDisposable
foreach (var channelName in _mainWindow.GetChannelNames())
_messageManager.AddStatusMessage(channelName, displayName, statusText);
});
FetchAndUpdateOnlineUsers();
// Update presence in all cached channel lists
List<UserPresenceDto>? snapshot = null;
lock (_channelUsersLock)
{
foreach (var (channel, users) in _channelUsers)
{
var idx = users.FindIndex(u => u.Username.Equals(presence.Username, StringComparison.OrdinalIgnoreCase));
if (idx >= 0)
{
if (presence.Status == UserStatus.Invisible)
users.RemoveAt(idx);
else
users[idx] = presence;
}
else if (presence.Status != UserStatus.Invisible)
{
// User came back from invisible — re-add them
users.Add(presence);
}
}
var currentChannel = _mainWindow.CurrentChannel;
if (!string.IsNullOrEmpty(currentChannel) && _channelUsers.TryGetValue(currentChannel, out var currentUsers))
snapshot = [.. currentUsers];
}
if (snapshot is not null)
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
};
_conn.UserKicked += (channelName, username, reason) =>
{
var reasonText = reason is not null ? $" ({reason})" : "";
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} was kicked{reasonText}"));
List<UserPresenceDto>? snapshot = null;
lock (_channelUsersLock)
{
if (_channelUsers.TryGetValue(channelName, out var users))
{
users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
snapshot = [.. users];
}
}
if (snapshot is not null)
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
};
_conn.UserBanned += (username, reason) =>
{
var reasonText = reason is not null ? $" ({reason})" : "";
List<UserPresenceDto>? snapshot = null;
lock (_channelUsersLock)
{
// Remove banned user from all cached channel lists
foreach (var (channel, users) in _channelUsers)
{
users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
}
var currentChannel = _mainWindow.CurrentChannel;
if (!string.IsNullOrEmpty(currentChannel) && _channelUsers.TryGetValue(currentChannel, out var currentUsers))
snapshot = [.. currentUsers];
}
InvokeUI(() =>
{
if (!username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase))
@@ -424,6 +518,9 @@ public sealed class AppOrchestrator : IDisposable
var channel = _mainWindow.CurrentChannel;
if (!string.IsNullOrEmpty(channel))
_messageManager.AddSystemMessage(channel, $"{username} was banned{reasonText}");
if (snapshot is not null)
_mainWindow.UpdateOnlineUsers(snapshot);
}
});
};
@@ -467,6 +564,7 @@ public sealed class AppOrchestrator : IDisposable
_conn.Reconnected += () =>
{
lock (_channelUsersLock) _channelUsers.Clear();
RunAsync(
async () => await _conn.RejoinChannelsAsync(),
"Failed to rejoin channels after reconnect");
@@ -524,9 +622,8 @@ public sealed class AppOrchestrator : IDisposable
if (result.DefaultHistory.Count > 0)
_messageManager.LoadHistory(HubConstants.DefaultChannel, result.DefaultHistory);
_mainWindow.FocusInput();
FetchAndUpdateOnlineUsers();
});
FetchAndUpdateOnlineUsers();
SaveServerToConfig(dialogResult);
}, "Connection failed", "Connect");
}
@@ -534,6 +631,7 @@ public sealed class AppOrchestrator : IDisposable
private void HandleDisconnect()
{
Log.Information("Disconnecting from server");
lock (_channelUsersLock) _channelUsers.Clear();
RunAsync(async () =>
{
@@ -622,6 +720,19 @@ public sealed class AppOrchestrator : IDisposable
}, "Failed to join channel");
}
private void HandleChannelJoinFromMessage(string channelName)
{
if (!_conn.IsConnected) return;
InvokeUI(() =>
{
_mainWindow.EnsureChannelInList(channelName);
_mainWindow.SwitchToChannel(channelName);
});
HandleChannelSelected(channelName);
}
private void HandleProfileRequested()
{
HandleViewProfile(null);
@@ -825,6 +936,8 @@ public sealed class AppOrchestrator : IDisposable
if (history.Count > 0)
_messageManager.LoadHistory(channel.Name, history);
});
FetchAndUpdateOnlineUsers();
}, "Failed to create channel");
}
@@ -880,6 +993,16 @@ public sealed class AppOrchestrator : IDisposable
}, "Failed to play audio");
}
/// <summary>
/// File extensions considered safe to open with the system default application.
/// Everything else is downloaded only — never auto-opened via UseShellExecute.
/// </summary>
private static readonly HashSet<string> SafeOpenExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".webm", ".mkv", ".avi", ".mov", // video
".pdf", ".txt", ".csv", ".json", ".xml", // documents
};
private void HandleFileDownloadRequested(string attachmentUrl, string fileName)
{
if (!_conn.IsAuthenticated) return;
@@ -889,19 +1012,58 @@ public sealed class AppOrchestrator : IDisposable
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
try
var ext = Path.GetExtension(fileName);
if (SafeOpenExtensions.Contains(ext))
{
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
System.Diagnostics.Process.Start(psi);
try
{
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
System.Diagnostics.Process.Start(psi);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
}
}
catch (Exception ex)
else
{
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
}
}, "Failed to download file");
}
private void HandleCheckForUpdatesRequested()
{
RunAsync(_updateService.CheckNowAsync, "Failed to check for updates");
}
private void HandleRollbackRequested()
{
if (!UpdateBackupService.BackupExists())
{
MessageBox.ErrorQuery(_app, "No Backup", "No backup is available to restore.", "OK");
return;
}
var info = UpdateBackupService.GetBackupInfo();
var confirm = MessageBox.Query(_app, "Rollback Update",
$"Restore to version {info?.Version ?? "unknown"}?\n\nThe app will restart.", "Restore", "Cancel");
if (confirm != 0) return;
try
{
UpdateBackupService.RestoreBackup();
// RestoreBackup calls Environment.Exit(0)
}
catch (Exception ex)
{
Log.Error(ex, "Rollback failed");
MessageBox.ErrorQuery(_app, "Rollback Failed", $"Could not restore: {ex.Message}", "OK");
}
}
// ── Private Helpers ────────────────────────────────────────────────────
private void FetchAndUpdateOnlineUsers()
@@ -914,6 +1076,7 @@ public sealed class AppOrchestrator : IDisposable
try
{
var users = await _conn.GetOnlineUsersAsync(channel);
lock (_channelUsersLock) _channelUsers[channel] = users;
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
}
catch (Exception ex)
+2 -3
View File
@@ -5,15 +5,14 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AlwaysUpToDate" Version="2.0.1" />
<PackageReference Include="AlwaysUpToDate" Version="2.0.2.20250223" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<!-- Using local fork until transparent color support is merged upstream (gui-cs/Terminal.Gui#4234) -->
<ProjectReference Include="..\Terminal.Gui\Terminal.Gui\Terminal.Gui.csproj" />
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5039" />
</ItemGroup>
<ItemGroup>
+82 -1
View File
@@ -1,12 +1,58 @@
using EchoHub.Client;
using EchoHub.Client.Config;
using EchoHub.Client.Services;
using EchoHub.Client.Themes;
using Microsoft.Extensions.Configuration;
using Serilog;
using Terminal.Gui.App;
using Terminal.Gui.Drawing;
// == CLI rollback: works without TUI, before anything else ================
if (args.Contains("--rollback"))
{
if (UpdateBackupService.BackupExists())
{
var info = UpdateBackupService.GetBackupInfo();
Console.WriteLine($"Rolling back to version {info?.Version ?? "unknown"}...");
try
{
UpdateBackupService.RestoreBackup();
// RestoreBackup calls Environment.Exit(0)
}
catch (Exception ex)
{
Console.Error.WriteLine($"Rollback failed: {ex.Message}");
Environment.Exit(1);
}
}
else
{
Console.Error.WriteLine("No backup available to restore.");
Environment.Exit(1);
}
}
// == Unix permission self-check (defense-in-depth after auto-update) ==
if (!OperatingSystem.IsWindows())
{
var exePath = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exePath))
{
try
{
var mode = File.GetUnixFileMode(exePath);
if ((mode & UnixFileMode.UserExecute) == 0)
{
File.SetUnixFileMode(exePath, mode | UnixFileMode.UserExecute);
}
}
catch
{
// Best-effort; if we're running, we already have execute permission
}
}
}
// == Normal startup ==
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
@@ -31,6 +77,41 @@ Log.Logger = new LoggerConfiguration()
Log.Information("EchoHub client starting");
// == Ensure echohub is on PATH for convenient terminal access ============
PathSetup.EnsureOnPath();
// == Post-update detection: stale backup cleanup or flag for rollback menu ================
if (UpdateBackupService.BackupExists())
{
var backupInfo = UpdateBackupService.GetBackupInfo();
if (backupInfo is not null && DateTimeOffset.UtcNow - backupInfo.CreatedAt > TimeSpan.FromDays(7))
{
Log.Information("Deleting stale update backup from {Date}", backupInfo.CreatedAt);
UpdateBackupService.DeleteBackup();
}
else
{
Log.Information("Post-update: backup of v{OldVersion} available for rollback",
backupInfo?.Version ?? "unknown");
UpdateBackupService.IsPostUpdate = true;
}
}
// == Windows: clean up .old executable left by rollback restore ===========
if (OperatingSystem.IsWindows())
{
var currentExe = Environment.ProcessPath;
if (!string.IsNullOrEmpty(currentExe))
{
var oldExe = currentExe + ".old";
if (File.Exists(oldExe))
{
try { File.Delete(oldExe); }
catch { /* locked or permission issue — will be cleaned next launch */ }
}
}
}
try
{
var config = ConfigManager.Load();
+32 -26
View File
@@ -32,7 +32,7 @@ public sealed class ApiClient : IDisposable
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
{
var request = new RegisterRequest(username, password, displayName);
var response = await _http.PostAsJsonAsync("/api/auth/register", request);
using var response = await _http.PostAsJsonAsync("/api/auth/register", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
@@ -45,7 +45,7 @@ public sealed class ApiClient : IDisposable
public async Task<LoginResponse> LoginAsync(string username, string password)
{
var request = new LoginRequest(username, password);
var response = await _http.PostAsJsonAsync("/api/auth/login", request);
using var response = await _http.PostAsJsonAsync("/api/auth/login", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
@@ -61,7 +61,7 @@ public sealed class ApiClient : IDisposable
throw new InvalidOperationException("No refresh token available.");
var request = new RefreshRequest(_refreshToken);
var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
using var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
@@ -73,7 +73,7 @@ public sealed class ApiClient : IDisposable
public async Task<LoginResponse> LoginWithRefreshTokenAsync(string refreshToken)
{
var request = new RefreshRequest(refreshToken);
var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
using var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
@@ -90,7 +90,7 @@ public sealed class ApiClient : IDisposable
try
{
var request = new RefreshRequest(_refreshToken);
await _http.PostAsJsonAsync("/api/auth/logout", request);
using var response = await _http.PostAsJsonAsync("/api/auth/logout", request);
}
catch
{
@@ -131,7 +131,7 @@ public sealed class ApiClient : IDisposable
public async Task<List<ChannelDto>> GetChannelsAsync()
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync("/api/channels");
using var response = await AuthenticatedGetAsync("/api/channels");
await EnsureSuccessAsync(response);
var paginated = await response.Content.ReadFromJsonAsync<PaginatedResponse<ChannelDto>>();
return paginated?.Items ?? [];
@@ -146,7 +146,7 @@ public sealed class ApiClient : IDisposable
public async Task<string> GetEncryptionKeyAsync()
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync("/api/server/encryption-key");
using var response = await AuthenticatedGetAsync("/api/server/encryption-key");
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<EncryptionKeyResponse>()
?? throw new InvalidOperationException("Server returned empty encryption key response.");
@@ -156,7 +156,7 @@ public sealed class ApiClient : IDisposable
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile");
using var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile");
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
}
@@ -164,7 +164,7 @@ public sealed class ApiClient : IDisposable
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PutAsJsonAsync("/api/users/profile", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
@@ -178,7 +178,7 @@ public sealed class ApiClient : IDisposable
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
content.Add(streamContent, "file", fileName);
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsync("/api/users/avatar", content));
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
@@ -194,7 +194,7 @@ public sealed class ApiClient : IDisposable
content.Add(streamContent, "file", fileName);
var sizeQuery = size is not null ? $"?size={size}" : "";
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<MessageDto>();
@@ -205,7 +205,7 @@ public sealed class ApiClient : IDisposable
EnsureAuthenticated();
var request = new SendUrlRequest(url);
var sizeQuery = size is not null ? $"?size={size}" : "";
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<MessageDto>();
@@ -214,7 +214,7 @@ public sealed class ApiClient : IDisposable
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync(relativeUrl);
using var response = await AuthenticatedGetAsync(relativeUrl);
await EnsureSuccessAsync(response);
var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub");
@@ -232,7 +232,7 @@ public sealed class ApiClient : IDisposable
{
EnsureAuthenticated();
var request = new CreateChannelRequest(name, topic, isPublic);
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/channels", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelDto>();
@@ -242,7 +242,7 @@ public sealed class ApiClient : IDisposable
{
EnsureAuthenticated();
var request = new UpdateTopicRequest(topic);
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelDto>();
@@ -251,7 +251,7 @@ public sealed class ApiClient : IDisposable
public async Task DeleteChannelAsync(string channelName)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}"));
await EnsureSuccessAsync(response);
}
@@ -261,7 +261,7 @@ public sealed class ApiClient : IDisposable
public async Task AssignRoleAsync(string username, ServerRole role)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
await EnsureSuccessAsync(response);
}
@@ -269,7 +269,7 @@ public sealed class ApiClient : IDisposable
public async Task KickUserAsync(string username, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
await EnsureSuccessAsync(response);
}
@@ -277,7 +277,7 @@ public sealed class ApiClient : IDisposable
public async Task BanUserAsync(string username, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
await EnsureSuccessAsync(response);
}
@@ -285,7 +285,7 @@ public sealed class ApiClient : IDisposable
public async Task UnbanUserAsync(string username)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
await EnsureSuccessAsync(response);
}
@@ -293,7 +293,7 @@ public sealed class ApiClient : IDisposable
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
await EnsureSuccessAsync(response);
}
@@ -301,7 +301,7 @@ public sealed class ApiClient : IDisposable
public async Task UnmuteUserAsync(string username)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
await EnsureSuccessAsync(response);
}
@@ -309,7 +309,7 @@ public sealed class ApiClient : IDisposable
public async Task DeleteMessageAsync(Guid messageId)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
await EnsureSuccessAsync(response);
}
@@ -317,7 +317,7 @@ public sealed class ApiClient : IDisposable
public async Task NukeChannelAsync(string channelName)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
await EnsureSuccessAsync(response);
}
@@ -333,6 +333,7 @@ public sealed class ApiClient : IDisposable
/// <summary>
/// Performs a GET request with automatic token refresh on 401.
/// Caller is responsible for disposing the returned response.
/// </summary>
private async Task<HttpResponseMessage> AuthenticatedGetAsync(string url)
{
@@ -343,7 +344,9 @@ public sealed class ApiClient : IDisposable
try
{
await RefreshTokenAsync();
response = await _http.GetAsync(url);
var retryResponse = await _http.GetAsync(url);
response.Dispose();
response = retryResponse;
}
catch
{
@@ -356,6 +359,7 @@ public sealed class ApiClient : IDisposable
/// <summary>
/// Performs a request with automatic token refresh on 401.
/// Caller is responsible for disposing the returned response.
/// </summary>
private async Task<HttpResponseMessage> AuthenticatedRequestAsync(Func<Task<HttpResponseMessage>> requestFactory)
{
@@ -366,7 +370,9 @@ public sealed class ApiClient : IDisposable
try
{
await RefreshTokenAsync();
response = await requestFactory();
var retryResponse = await requestFactory();
response.Dispose();
response = retryResponse;
}
catch
{
@@ -6,6 +6,7 @@ namespace EchoHub.Client.Services;
public class AudioPlaybackService
{
private readonly Player _player = new();
private readonly SemaphoreSlim _lock = new(1, 1);
public bool IsPlaying => _player.Playing;
public bool IsPaused => _player.Paused;
@@ -19,6 +20,7 @@ public class AudioPlaybackService
public async Task PlayAsync(string filePath)
{
await _lock.WaitAsync();
try
{
if (_player.Playing)
@@ -30,10 +32,15 @@ public class AudioPlaybackService
{
Log.Warning(ex, "Failed to play audio file: {Path}", filePath);
}
finally
{
_lock.Release();
}
}
public async Task PauseAsync()
{
await _lock.WaitAsync();
try
{
if (_player.Playing && !_player.Paused)
@@ -43,10 +50,15 @@ public class AudioPlaybackService
{
Log.Warning(ex, "Failed to pause audio playback");
}
finally
{
_lock.Release();
}
}
public async Task ResumeAsync()
{
await _lock.WaitAsync();
try
{
if (_player.Paused)
@@ -56,23 +68,33 @@ public class AudioPlaybackService
{
Log.Warning(ex, "Failed to resume audio playback");
}
finally
{
_lock.Release();
}
}
public async Task StopAsync()
{
await _lock.WaitAsync();
try
{
if (_player.Playing)
if (_player.Playing || _player.Paused)
await _player.Stop();
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to stop audio playback");
}
finally
{
_lock.Release();
}
}
public async Task SetVolumeAsync(byte volume)
{
await _lock.WaitAsync();
try
{
await _player.SetVolume(Math.Min(volume, (byte)100));
@@ -81,5 +103,9 @@ public class AudioPlaybackService
{
Log.Warning(ex, "Failed to set audio volume");
}
finally
{
_lock.Release();
}
}
}
@@ -35,7 +35,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
// ── Events (forwarded from SignalR) ───────────────────────────────────
public event Action<MessageDto>? MessageReceived;
public event Action<string, string>? UserJoined;
public event Action<string, string, UserPresenceDto?>? UserJoined;
public event Action<string, string>? UserLeft;
public event Action<UserPresenceDto>? UserStatusChanged;
public event Action<string, string, string?>? UserKicked;
@@ -60,77 +60,83 @@ internal sealed class ConnectionManager : IAsyncDisposable
_apiClient?.Dispose();
_apiClient = new ApiClient(info.ServerUrl);
onStatus("Authenticating...");
LoginResponse loginResponse;
if (info.SavedRefreshToken is not null)
try
{
try
onStatus("Authenticating...");
LoginResponse loginResponse;
if (info.SavedRefreshToken is not null)
{
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken);
Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
}
else if (info.IsRegister)
{
loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
}
else
{
loginResponse = await _apiClient.LoginAsync(info.Username, info.Password);
}
// Auto-persist rotated refresh tokens for Remember Me
_apiClient.OnTokensRefreshed += HandleTokensRefreshed;
// E2E encryption key
onStatus("Fetching encryption key...");
try
{
var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
_encryption.SetKey(encryptionKey);
Log.Information("E2E encryption key established");
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
}
onStatus("Authenticated, connecting...");
if (_connection is not null)
await _connection.DisposeAsync();
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
WireConnectionEvents(_connection);
await _connection.ConnectAsync();
var channels = await _apiClient.GetChannelsAsync();
onStatus("Connected");
// Join default channel + fetch history
_joinedChannels.Clear();
_joinedChannels.Add(HubConstants.DefaultChannel);
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
List<MessageDto> history = [];
try
{
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
}
catch
{
_apiClient.Dispose();
_apiClient = null;
throw; // Caller handles saved-session expiry
// History might not be available
}
}
else if (info.IsRegister)
{
loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
}
else
{
loginResponse = await _apiClient.LoginAsync(info.Username, info.Password);
}
// Auto-persist rotated refresh tokens for Remember Me
_apiClient.OnTokensRefreshed += HandleTokensRefreshed;
// E2E encryption key
onStatus("Fetching encryption key...");
try
{
var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
_encryption.SetKey(encryptionKey);
Log.Information("E2E encryption key established");
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
}
onStatus("Authenticated, connecting...");
if (_connection is not null)
await _connection.DisposeAsync();
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
WireConnectionEvents(_connection);
await _connection.ConnectAsync();
var channels = await _apiClient.GetChannelsAsync();
onStatus("Connected");
// Join default channel + fetch history
_joinedChannels.Clear();
_joinedChannels.Add(HubConstants.DefaultChannel);
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
List<MessageDto> history = [];
try
{
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
return new ConnectResult(loginResponse, channels, history);
}
catch
{
// History might not be available
}
if (_connection is not null)
{
await _connection.DisposeAsync();
_connection = null;
}
return new ConnectResult(loginResponse, channels, history);
_apiClient.Dispose();
_apiClient = null;
throw;
}
}
// ── Cleanup ───────────────────────────────────────────────────────────
@@ -228,7 +234,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
private void WireConnectionEvents(EchoHubConnection connection)
{
connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg);
connection.OnUserJoined += (ch, user) => UserJoined?.Invoke(ch, user);
connection.OnUserJoined += (ch, user, presence) => UserJoined?.Invoke(ch, user, presence);
connection.OnUserLeft += (ch, user) => UserLeft?.Invoke(ch, user);
connection.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p);
connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason);
@@ -11,7 +11,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
private readonly ClientEncryptionService _encryption;
public event Action<MessageDto>? OnMessageReceived;
public event Action<string, string>? OnUserJoined;
public event Action<string, string, UserPresenceDto?>? OnUserJoined;
public event Action<string, string>? OnUserLeft;
public event Action<ChannelDto>? OnChannelUpdated;
public event Action<UserPresenceDto>? OnUserStatusChanged;
@@ -70,9 +70,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
OnMessageReceived?.Invoke(decrypted);
});
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
_connection.On<string, string, UserPresenceDto?>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) =>
{
OnUserJoined?.Invoke(channelName, username);
OnUserJoined?.Invoke(channelName, username, presence);
});
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) =>
@@ -136,8 +136,10 @@ public sealed class EchoHubConnection : IAsyncDisposable
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
{
var messages = await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
return DecryptMessages(messages);
var result = await _connection.InvokeAsync<JoinChannelResult>("JoinChannel", channelName);
if (!result.Success)
throw new InvalidOperationException(result.Error ?? "Failed to join channel.");
return DecryptMessages(result.History);
}
public async Task LeaveChannelAsync(string channelName)
+102
View File
@@ -0,0 +1,102 @@
using Serilog;
namespace EchoHub.Client.Services;
/// <summary>
/// Ensures the application's directory is on the system PATH so users
/// can run 'echohub' from any terminal session.
/// </summary>
public static class PathSetup
{
private const string PathMarker = "# Added by EchoHub";
/// <summary>
/// Checks if the app directory is on PATH; if not, adds it persistently.
/// On Windows: modifies user-level PATH environment variable.
/// On Linux/macOS: appends an export line to shell profile files.
/// </summary>
public static void EnsureOnPath()
{
try
{
var appDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (IsOnPath(appDir))
return;
if (OperatingSystem.IsWindows())
AddToWindowsPath(appDir);
else
AddToUnixPath(appDir);
}
catch (Exception ex)
{
Log.Debug(ex, "Could not add app directory to PATH");
}
}
private static bool IsOnPath(string directory)
{
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
var separator = OperatingSystem.IsWindows() ? ';' : ':';
return pathVar
.Split(separator, StringSplitOptions.RemoveEmptyEntries)
.Any(p => string.Equals(
p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
directory,
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal));
}
private static void AddToWindowsPath(string directory)
{
var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? "";
// Double-check against user PATH specifically (process PATH includes system + user)
if (userPath.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Any(p => string.Equals(p.TrimEnd('\\', '/'), directory, StringComparison.OrdinalIgnoreCase)))
return;
var newPath = string.IsNullOrEmpty(userPath) ? directory : userPath + ";" + directory;
Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.User);
Log.Information("Added {Directory} to user PATH", directory);
}
private static void AddToUnixPath(string directory)
{
var exportLine = $"export PATH=\"{directory}:$PATH\" {PathMarker}";
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// Target the most common shell profiles
string[] profiles = [
Path.Combine(home, ".profile"),
Path.Combine(home, ".bashrc"),
Path.Combine(home, ".zshrc")
];
var added = false;
foreach (var profile in profiles)
{
if (!File.Exists(profile))
continue;
var content = File.ReadAllText(profile);
if (content.Contains(directory))
continue; // Already present (manual or previous run)
File.AppendAllText(profile, $"\n{exportLine}\n");
added = true;
Log.Information("Added PATH export to {Profile}", profile);
}
// If no profile existed, create .profile
if (!added)
{
var fallback = Path.Combine(home, ".profile");
File.AppendAllText(fallback, $"\n{exportLine}\n");
Log.Information("Created PATH export in {Profile}", fallback);
}
}
}
@@ -0,0 +1,153 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Text.Json;
using Serilog;
namespace EchoHub.Client.Services;
/// <summary>
/// Manages pre-update backups and rollback restoration for the auto-updater.
/// Backup location: ~/.echohub/update-backup/
/// </summary>
public static class UpdateBackupService
{
private static readonly string BackupDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".echohub", "update-backup");
private static readonly string BackupZipPath = Path.Combine(BackupDir, "backup.zip");
private static readonly string BackupInfoPath = Path.Combine(BackupDir, "backup-info.json");
/// <summary>
/// True if a backup exists from a recent update (set at startup).
/// </summary>
public static bool IsPostUpdate { get; set; }
/// <summary>
/// Creates a ZIP backup of the current app directory before an update.
/// Deletes any previous backup first. Uses fastest compression for speed.
/// </summary>
public static void CreateBackup()
{
var appDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var version = UpdateChecker.CurrentVersion;
if (Directory.Exists(BackupDir))
Directory.Delete(BackupDir, true);
Directory.CreateDirectory(BackupDir);
Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version);
ZipFile.CreateFromDirectory(appDir, BackupZipPath, CompressionLevel.Fastest, includeBaseDirectory: false);
var info = new BackupInfo(version, appDir, DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo);
File.WriteAllText(BackupInfoPath, json);
Log.Information("Backup created at {BackupPath}", BackupZipPath);
}
/// <summary>
/// Returns true if a valid backup exists (both ZIP and metadata file present).
/// </summary>
public static bool BackupExists()
=> File.Exists(BackupZipPath) && File.Exists(BackupInfoPath);
/// <summary>
/// Reads backup metadata. Returns null if no backup exists or metadata is unreadable.
/// </summary>
public static BackupInfo? GetBackupInfo()
{
if (!File.Exists(BackupInfoPath))
return null;
try
{
var json = File.ReadAllText(BackupInfoPath);
return JsonSerializer.Deserialize(json, BackupJsonContext.Default.BackupInfo);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to read backup metadata");
return null;
}
}
/// <summary>
/// Restores the backup ZIP to the app directory, then restarts the process.
/// This method does not return — it calls Environment.Exit(0).
/// </summary>
public static void RestoreBackup()
{
var info = GetBackupInfo()
?? throw new InvalidOperationException("No backup metadata found");
var appDir = info.AppDirectory;
Log.Information("Restoring backup v{Version} to {AppDir}", info.Version, appDir);
// On Windows, rename the running executable so extraction can overwrite it
if (OperatingSystem.IsWindows())
{
var currentExe = Environment.ProcessPath;
if (!string.IsNullOrEmpty(currentExe) && File.Exists(currentExe))
{
var oldExe = currentExe + ".old";
if (File.Exists(oldExe))
File.Delete(oldExe);
File.Move(currentExe, oldExe);
}
}
ZipFile.ExtractToDirectory(BackupZipPath, appDir, overwriteFiles: true);
// Restore execute permission on Unix
if (!OperatingSystem.IsWindows())
{
var exePath = Environment.ProcessPath
?? Path.Combine(appDir, "EchoHub.Client");
if (File.Exists(exePath))
{
var mode = File.GetUnixFileMode(exePath);
File.SetUnixFileMode(exePath, mode | UnixFileMode.UserExecute);
}
}
// Start the restored version and exit
var processPath = Environment.ProcessPath
?? Path.Combine(appDir, "EchoHub.Client");
Log.Information("Launching restored version v{Version}: {Path}", info.Version, processPath);
Process.Start(new ProcessStartInfo(processPath) { UseShellExecute = false });
Environment.Exit(0);
}
/// <summary>
/// Deletes the backup directory and all contents.
/// </summary>
public static void DeleteBackup()
{
if (!Directory.Exists(BackupDir))
return;
try
{
Directory.Delete(BackupDir, true);
Log.Information("Update backup deleted");
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to delete update backup");
}
}
}
public record BackupInfo(
string Version,
string AppDirectory,
DateTimeOffset CreatedAt);
[System.Text.Json.Serialization.JsonSerializable(typeof(BackupInfo))]
internal partial class BackupJsonContext : System.Text.Json.Serialization.JsonSerializerContext;
+89 -6
View File
@@ -5,6 +5,7 @@ using EchoHub.Client.UI.Dialogs;
using Serilog;
using Terminal.Gui.App;
using Terminal.Gui.Views;
namespace EchoHub.Client.Services;
@@ -15,6 +16,8 @@ public sealed class UpdateChecker : IDisposable
private readonly Updater _updater;
private readonly IApplication _app;
private UpdateProgressDialog? _progressDialog;
private bool _manualCheck;
public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
@@ -37,27 +40,70 @@ public sealed class UpdateChecker : IDisposable
#endif
}
public async Task CheckNowAsync()
{
_manualCheck = true;
try
{
await _updater.CheckForUpdateAsync();
}
finally
{
_manualCheck = false;
}
}
private async void OnUpdateAvailable(string version, string changelogUrl)
{
Log.Information("Update available: v{Version}", version);
var confirmed = false;
_app.Invoke(() =>
{
confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
var confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
if (confirmed)
{
_progressDialog = new UpdateProgressDialog(_app, version);
// Start the update; progress is reported via OnProgressChanged
_ = Task.Run(async () =>
{
// Create backup before the update starts
try
{
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Creating backup..."));
UpdateBackupService.CreateBackup();
}
catch (Exception ex)
{
Log.Error(ex, "Failed to create pre-update backup");
var proceed = false;
_app.Invoke(() =>
{
proceed = MessageBox.Query(
_app,
"Backup Warning",
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
"Continue", "Cancel") == 0;
});
if (!proceed)
{
_app.Invoke(() =>
{
_progressDialog?.Close();
_progressDialog = null;
});
return;
}
}
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Downloading update..."));
await _updater.UpdateAsync();
});
_progressDialog?.Show();
_progressDialog.Show();
}
});
}
@@ -83,15 +129,52 @@ public sealed class UpdateChecker : IDisposable
private void OnNoUpdateAvailable()
{
Log.Debug("No update available");
if (_manualCheck)
{
_app.Invoke(() =>
{
MessageBox.Query(_app, "Check for Updates", $"You are already on the latest version (v{CurrentVersion}).", "OK");
});
}
}
private void OnException(Exception exception)
{
Log.Error(exception, "Update check failed");
Log.Error(exception, "Update failed");
_app.Invoke(() =>
{
_progressDialog?.Close();
_progressDialog = null;
if (UpdateBackupService.BackupExists())
{
var restore = MessageBox.Query(
_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
{
UpdateBackupService.RestoreBackup();
// RestoreBackup calls Environment.Exit(0)
}
catch (Exception restoreEx)
{
Log.Error(restoreEx, "Backup restoration failed");
MessageBox.ErrorQuery(_app, "Restore Failed",
$"Could not restore backup: {restoreEx.Message}\n\nYou may need to re-download the application.", "OK");
}
}
}
else
{
MessageBox.ErrorQuery(_app, "Update Failed",
$"The update failed: {exception.Message}\n\nYou may need to re-download the application.", "OK");
}
});
}
+36 -3
View File
@@ -13,6 +13,7 @@ public static partial class ChatColors
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
public static readonly Attribute ChannelRefAttr = new(new Color(100, 200, 255), Color.None);
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
@@ -21,8 +22,8 @@ public static partial class ChatColors
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
/// <summary>
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
/// Non-mention text uses the provided default color.
/// Split text around @mentions and #channels, giving each the appropriate accent color.
/// Non-special text uses the provided default color.
/// </summary>
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
{
@@ -38,12 +39,44 @@ public static partial class ChatColors
lastIndex = match.Index + match.Length;
}
// Add remaining text after the last mention (or all text if no mentions found)
if (lastIndex < text.Length)
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
// Second pass: highlight #channels in non-mention segments
var mentionSegments = segments;
segments = [];
foreach (var seg in mentionSegments)
{
if (seg.Color != null && seg.Color != defaultColor)
{
// Already colored (mention) — keep as-is
segments.Add(seg);
continue;
}
int segLast = 0;
foreach (Match match in ChannelRefRegex().Matches(seg.Text))
{
if (match.Index > segLast)
segments.Add(new ChatSegment(seg.Text[segLast..match.Index], defaultColor));
segments.Add(new ChatSegment(match.Value, ChannelRefAttr));
segLast = match.Index + match.Length;
}
if (segLast < seg.Text.Length)
segments.Add(new ChatSegment(seg.Text[segLast..], defaultColor));
}
return segments;
}
[GeneratedRegex(@"@[\w-]+")]
// @mention — not preceded by a word char (avoids emails)
[GeneratedRegex(@"(?<!\w)@[\w-]+")]
private static partial Regex MentionRegex();
// #channel — not preceded by a word char, must contain at least one letter (avoids hex colors / issue numbers)
[GeneratedRegex(@"(?<!\w)#(?=.*[a-zA-Z])[\w-]+")]
private static partial Regex ChannelRefRegex();
}
+69 -37
View File
@@ -1,3 +1,4 @@
using System.Text;
using System.Text.RegularExpressions;
using EchoHub.Core.Models;
using Terminal.Gui.Drawing;
@@ -18,6 +19,9 @@ public partial class ChatLine
public string? AttachmentUrl { get; set; }
public string? AttachmentFileName { get; set; }
public MessageType? Type { get; set; }
public string? SenderUsername { get; set; }
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
public int ContinuationIndent { get; set; }
public ChatLine(string plainText)
{
@@ -42,59 +46,87 @@ public partial class ChatLine
if (width <= 0 || TextLength <= width)
return [this];
var results = new List<ChatLine>();
var currentSegments = new List<ChatSegment>();
int col = 0;
var tokens = new List<(string grapheme, Attribute? color)>();
foreach (var segment in Segments)
foreach (var g in GraphemeHelper.GetGraphemes(segment.Text))
tokens.Add((g, segment.Color));
var results = new List<ChatLine>();
int pos = 0;
bool firstLine = true;
while (pos < tokens.Count)
{
var text = segment.Text;
int chunkStart = 0;
int charPos = 0;
// First line uses the full width
int col = firstLine ? 0 : continuationIndent;
int lastSpaceIdx = -1;
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
int i = pos;
for (; i < tokens.Count; i++)
{
var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
if (col + graphemeCols > width)
{
if (charPos > chunkStart)
currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
results.Add(new ChatLine(currentSegments));
currentSegments = [];
if (continuationIndent > 0)
{
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
col = continuationIndent;
}
else
{
col = 0;
}
chunkStart = charPos;
}
var graphemeCols = Math.Max(tokens[i].grapheme.GetColumns(), 1);
if (col + graphemeCols > width) break;
if (tokens[i].grapheme == " ") lastSpaceIdx = i;
col += graphemeCols;
charPos += grapheme.Length;
}
if (chunkStart < text.Length)
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
int lineEnd, nextPos;
if (i == tokens.Count)
{
// All remaining tokens fit on this line.
lineEnd = tokens.Count;
nextPos = tokens.Count;
}
else if (lastSpaceIdx >= pos)
{
// Break at the last space that fit, skip the space itself.
lineEnd = lastSpaceIdx;
nextPos = lastSpaceIdx + 1;
}
else
{
// No space found, break word.
lineEnd = Math.Max(i, pos + 1);
nextPos = lineEnd;
}
var segments = new List<ChatSegment>();
if (!firstLine && continuationIndent > 0)
segments.Add(new ChatSegment(new string(' ', continuationIndent), null));
// Rebuild segments by grouping consecutive same-color tokens.
var sb = new StringBuilder();
int groupStart = pos;
while (groupStart < lineEnd)
{
var color = tokens[groupStart].color;
sb.Clear();
int groupEnd = groupStart;
while (groupEnd < lineEnd && tokens[groupEnd].color == color)
{
sb.Append(tokens[groupEnd].grapheme);
groupEnd++;
}
segments.Add(new ChatSegment(sb.ToString(), color));
groupStart = groupEnd;
}
results.Add(new ChatLine(segments));
pos = nextPos;
firstLine = false;
}
if (currentSegments.Count > 0)
results.Add(new ChatLine(currentSegments));
if (results.Count == 0)
return [this];
// Propagate attachment/type metadata to all wrapped lines so they remain clickable
// Propagate metadata to all wrapped lines so they remain clickable
foreach (var wrapped in results)
{
wrapped.AttachmentUrl = AttachmentUrl;
wrapped.AttachmentFileName = AttachmentFileName;
wrapped.Type = Type;
wrapped.MessageId = MessageId;
wrapped.SenderUsername = SenderUsername;
}
return results;
@@ -259,6 +259,9 @@ public sealed class ChatMessageManager
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
}
foreach (var l in lines)
l.ContinuationIndent = indent.Length;
if (message.Embeds is { Count: > 0 })
{
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
@@ -269,7 +272,10 @@ public sealed class ChatMessageManager
}
foreach (var line in lines)
{
line.MessageId = message.Id;
line.SenderUsername = message.SenderUsername;
}
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
{
@@ -326,18 +332,20 @@ public sealed class ChatMessageManager
int textWidth = chatWidth - indentCols - borderCols;
if (textWidth < 20) textWidth = 20;
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
void AddTextLine(string text, Attribute? color)
{
lines.Add(new ChatLine(
[
new ChatSegment(indent, null),
new ChatSegment(border, ChatColors.EmbedBorderAttr),
new ChatSegment(border, borderAttr),
new ChatSegment(text, color)
]));
}
if (!string.IsNullOrWhiteSpace(embed.SiteName))
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
AddTextLine(embed.SiteName, borderAttr);
if (!string.IsNullOrWhiteSpace(embed.Title))
{
@@ -23,7 +23,7 @@ public sealed class AudioPlayerDialog
public static void Show(IApplication app, AudioPlaybackService audioService, string filePath, string fileName)
{
var dialog = new Dialog { Title = "Audio Player", Width = 52, Height = 14 };
var dialog = new Dialog { Title = "Audio Player", Width = 52, Height = 12 };
// ── File name ──
var fileLabel = new Label
@@ -61,38 +61,47 @@ public sealed class AudioPlayerDialog
};
byte currentVolume = 50;
var volumeBar = new ProgressBar
{
X = 14,
Y = 7,
Width = 20,
Height = 1,
Fraction = currentVolume / 100f,
ProgressBarStyle = ProgressBarStyle.Continuous
};
var volumePercentLabel = new Label
{
Text = $"{currentVolume}%",
X = 35,
Y = 7,
Width = 5
};
var volDownButton = new Button
{
Text = "-",
X = 10,
Y = 7,
Width = 3
Width = 1,
Height = 1,
NoDecorations = true,
NoPadding = true,
ShadowStyle = ShadowStyle.None
};
var volumeBar = new ProgressBar
{
X = 12,
Y = 7,
Width = 22,
Height = 1,
Fraction = currentVolume / 100f,
ProgressBarStyle = ProgressBarStyle.Continuous
};
var volUpButton = new Button
{
Text = "+",
X = 41,
X = 35,
Y = 7,
Width = 3
Width = 1,
Height = 1,
NoDecorations = true,
NoPadding = true,
ShadowStyle = ShadowStyle.None
};
var volumePercentLabel = new Label
{
Text = $"{currentVolume}%",
X = 37,
Y = 7,
Width = 5
};
// ── Playback controls ──
@@ -100,22 +109,25 @@ public sealed class AudioPlayerDialog
{
Text = "\u25b6 Play",
X = 2,
Y = 10,
IsDefault = true
Y = 9,
IsDefault = true,
ShadowStyle = ShadowStyle.None
};
var stopButton = new Button
{
Text = "\u25a0 Stop",
X = Pos.Right(playButton) + 2,
Y = 10
X = Pos.Right(playButton) + 1,
Y = 9,
ShadowStyle = ShadowStyle.None
};
var closeButton = new Button
{
Text = "Close",
X = Pos.Right(stopButton) + 2,
Y = 10
X = Pos.Right(stopButton) + 1,
Y = 9,
ShadowStyle = ShadowStyle.None
};
// ── Animation state ──
@@ -128,6 +140,7 @@ public sealed class AudioPlayerDialog
Timer? animationTimer = null;
var isDisposed = false;
var isBusy = false;
// ── Helper functions ──
void UpdateWave(bool isActive)
@@ -278,6 +291,8 @@ public sealed class AudioPlayerDialog
playButton.Accepting += (s, e) =>
{
e.Handled = true;
if (isBusy) return;
isBusy = true;
Task.Run(async () =>
{
if (audioService.IsPaused)
@@ -287,6 +302,7 @@ public sealed class AudioPlayerDialog
{
UpdateStatus();
StartAnimation();
isBusy = false;
});
}
else if (audioService.IsPlaying)
@@ -296,6 +312,7 @@ public sealed class AudioPlayerDialog
{
UpdateStatus();
StopAnimation();
isBusy = false;
});
}
else
@@ -306,6 +323,7 @@ public sealed class AudioPlayerDialog
{
UpdateStatus();
StartAnimation();
isBusy = false;
});
}
});
@@ -314,6 +332,8 @@ public sealed class AudioPlayerDialog
stopButton.Accepting += (s, e) =>
{
e.Handled = true;
if (isBusy) return;
isBusy = true;
Task.Run(async () =>
{
await audioService.StopAsync();
@@ -321,6 +341,7 @@ public sealed class AudioPlayerDialog
{
UpdateStatus();
StopAnimation();
isBusy = false;
});
});
};
@@ -337,6 +358,8 @@ public sealed class AudioPlayerDialog
volDownButton.Accepting += (s, e) =>
{
e.Handled = true;
if (isBusy) return;
isBusy = true;
var newVol = (byte)Math.Max(0, currentVolume - 10);
Task.Run(async () =>
{
@@ -345,6 +368,7 @@ public sealed class AudioPlayerDialog
{
volumeBar.SetNeedsDraw();
volumePercentLabel.SetNeedsDraw();
isBusy = false;
});
});
};
@@ -352,6 +376,8 @@ public sealed class AudioPlayerDialog
volUpButton.Accepting += (s, e) =>
{
e.Handled = true;
if (isBusy) return;
isBusy = true;
var newVol = (byte)Math.Min(100, currentVolume + 10);
Task.Run(async () =>
{
@@ -360,6 +386,7 @@ public sealed class AudioPlayerDialog
{
volumeBar.SetNeedsDraw();
volumePercentLabel.SetNeedsDraw();
isBusy = false;
});
});
};
@@ -12,14 +12,14 @@ namespace EchoHub.Client.UI.ListSources;
/// </summary>
public class UserListSource : IListDataSource
{
private readonly List<(string Text, Attribute? NameColor)> _users = [];
private readonly List<(string Text, Attribute? NameColor, string Username)> _users = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _users.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
public void Update(List<(string Text, Attribute? NameColor)> users)
public void Update(List<(string Text, Attribute? NameColor, string Username)> users)
{
_users.Clear();
_users.AddRange(users);
@@ -28,15 +28,18 @@ public class UserListSource : IListDataSource
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public string? GetUsername(int index) =>
index >= 0 && index < _users.Count ? _users[index].Username : null;
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _users.Select(u => u.Text).ToList();
public IList ToList() => _users.Select(u => (object)u.Text).ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var (text, nameColor) = _users[item];
var (text, nameColor, _) = _users[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
// Find where the name starts (after status icon + space + optional role badge)
+103 -14
View File
@@ -1,3 +1,5 @@
using System.Text.RegularExpressions;
using EchoHub.Client.Services;
using EchoHub.Client.Themes;
using EchoHub.Client.UI.Chat;
using EchoHub.Client.UI.Helpers;
@@ -18,7 +20,7 @@ namespace EchoHub.Client.UI;
/// <summary>
/// Main Terminal.Gui window for the EchoHub chat client.
/// </summary>
public sealed class MainWindow : Runnable
public sealed partial class MainWindow : Runnable
{
private readonly IApplication _app;
private readonly ListView _channelList;
@@ -104,6 +106,11 @@ public sealed class MainWindow : Runnable
/// </summary>
public event Action<string>? OnThemeSelected;
/// <summary>
/// Fired when the user requests to check for updates.
/// </summary>
public event Action? OnCheckForUpdatesRequested;
/// <summary>
/// Fired when the user requests to view saved servers.
/// </summary>
@@ -119,6 +126,11 @@ public sealed class MainWindow : Runnable
/// </summary>
public event Action? OnDeleteChannelRequested;
/// <summary>
/// Fired when the user requests to rollback to the previous version.
/// </summary>
public event Action? OnRollbackRequested;
/// <summary>
/// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
/// </summary>
@@ -129,6 +141,16 @@ public sealed class MainWindow : Runnable
/// </summary>
public event Action<string, string>? OnFileDownloadRequested;
/// <summary>
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
/// </summary>
public event Action<string>? OnUserProfileRequested;
/// <summary>
/// Fired when the user activates a #channel reference in a message. Parameter is the channel name.
/// </summary>
public event Action<string>? OnChannelJoinRequested;
public MainWindow(IApplication app, ChatMessageManager messageManager)
{
_app = app;
@@ -239,6 +261,7 @@ public sealed class MainWindow : Runnable
};
_usersListSource = new UserListSource();
_usersList.Source = _usersListSource;
_usersList.Accepting += OnUsersListAccepting;
_usersFrame.Add(_usersList);
Add(_usersFrame);
@@ -318,12 +341,20 @@ public sealed class MainWindow : Runnable
};
allUserItems.AddRange(themeItems);
var fileItems = new List<View>();
if (UpdateBackupService.BackupExists())
{
var info = UpdateBackupService.GetBackupInfo();
var label = info is not null ? $"_Rollback to v{info.Version}..." : "_Rollback Update...";
fileItems.Add(new MenuItem(label, "Restore previous version", () => OnRollbackRequested?.Invoke(), Key.Empty));
fileItems.Add(new Line());
}
fileItems.Add(new MenuItem($"_Check for Updates", "Check for new version", () => OnCheckForUpdatesRequested?.Invoke(), Key.Empty));
fileItems.Add(new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty));
var menuBar = new MenuBar(
[
new MenuBarItem("_File",
[
new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty)
]),
new MenuBarItem("_File", fileItems),
new MenuBarItem("_Server", new View[]
{
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
@@ -391,17 +422,66 @@ public sealed class MainWindow : Runnable
return;
var line = source.GetLine(index.Value);
if (line?.AttachmentUrl is null || line.AttachmentFileName is null)
return;
if (line is null) return;
if (line.Type == MessageType.Audio)
// Audio/file attachments take priority
if (line.AttachmentUrl is not null && line.AttachmentFileName is not null)
{
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
if (line.Type == MessageType.Audio)
{
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
e.Handled = true;
return;
}
if (line.Type == MessageType.File)
{
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
e.Handled = true;
return;
}
}
var lineText = line.ToString();
// Check for @mention — open mentioned user's profile
// Negative lookbehind prevents matching emails (user@domain)
var mentionMatch = ClickMentionRegex().Match(lineText);
if (mentionMatch.Success)
{
OnUserProfileRequested?.Invoke(mentionMatch.Groups[1].Value);
e.Handled = true;
return;
}
// Check for #channel — join/switch to that channel
// Require at least one letter to avoid matching hex colors (#ff0000) or issue numbers (#123)
var channelMatch = ClickChannelRegex().Match(lineText);
if (channelMatch.Success)
{
OnChannelJoinRequested?.Invoke(channelMatch.Groups[1].Value);
e.Handled = true;
return;
}
// Default: open sender's profile
if (line.SenderUsername is not null)
{
OnUserProfileRequested?.Invoke(line.SenderUsername);
e.Handled = true;
}
else if (line.Type == MessageType.File)
}
private void OnUsersListAccepting(object? sender, CommandEventArgs e)
{
var index = _usersList.SelectedItem;
if (!index.HasValue || index.Value < 0 || index.Value >= _usersListSource.Count)
return;
var username = _usersListSource.GetUsername(index.Value);
if (username is not null)
{
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
OnUserProfileRequested?.Invoke(username);
e.Handled = true;
}
}
@@ -753,7 +833,7 @@ public sealed class MainWindow : Runnable
if (width > 0)
{
foreach (var line in messages)
source.AddRange(line.Wrap(width));
source.AddRange(line.Wrap(width, line.ContinuationIndent));
}
else
{
@@ -848,9 +928,11 @@ public sealed class MainWindow : Runnable
ServerRole.Mod => "\u2740", // ❀
_ => ""
};
var text = $"{statusIcon} {roleTag}{name}";
var text = roleTag.Length > 0
? $"{statusIcon} {roleTag} {name}"
: $"{statusIcon} {name}";
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
return (text, nameColor);
return (text, nameColor, u.Username);
}).ToList();
_usersListSource.Update(displayItems);
@@ -858,4 +940,11 @@ public sealed class MainWindow : Runnable
_usersFrame.Title = $"Users ({users.Count})";
}
// @mention — not preceded by a word char (avoids emails)
[GeneratedRegex(@"(?<!\w)@([\w-]+)")]
private static partial Regex ClickMentionRegex();
// #channel — not preceded by a word char, must contain at least one letter (avoids hex colors / issue numbers)
[GeneratedRegex(@"(?<!\w)#((?=.*[a-zA-Z])[\w-]+)")]
private static partial Regex ClickChannelRegex();
}
@@ -5,7 +5,7 @@ namespace EchoHub.Core.Contracts;
public interface IChatBroadcaster
{
Task SendMessageToChannelAsync(string channelName, MessageDto message);
Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null);
Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null);
Task SendUserLeftAsync(string channelName, string username);
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
+1 -3
View File
@@ -25,8 +25,6 @@ public interface IChatService
Task BroadcastMessageAsync(string channelName, MessageDto message);
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
// Query operations (used by IRC gateway for WHOIS, AUTH)
Task<UserProfileDto?> GetUserProfileAsync(string username);
// Query operations (used by IRC gateway for WHOIS)
Task<List<string>> GetChannelsForUserAsync(string username);
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
}
+1 -1
View File
@@ -8,7 +8,7 @@ namespace EchoHub.Core.Contracts;
public interface IEchoHubClient
{
Task ReceiveMessage(MessageDto message);
Task UserJoined(string channelName, string username);
Task UserJoined(string channelName, string username, UserPresenceDto? presence);
Task UserLeft(string channelName, string username);
Task ChannelUpdated(ChannelDto channel);
Task UserStatusChanged(UserPresenceDto presence);
@@ -0,0 +1,13 @@
using EchoHub.Core.DTOs;
namespace EchoHub.Core.Contracts;
public interface IUserService
{
Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null);
Task<UserOperationResult> AuthenticateUserAsync(string username, string password);
Task<UserProfileDto?> GetUserProfileAsync(string username);
Task<UserProfileDto?> GetUserByIdAsync(Guid userId);
Task<UserOperationResult> UpdateProfileAsync(Guid userId, string? displayName, string? bio, string? nicknameColor);
Task<UserOperationResult> SetAvatarAsync(Guid userId, string asciiArt);
}
+4 -1
View File
@@ -39,9 +39,12 @@ public record UpdateTopicRequest(string? Topic);
public record SendUrlRequest(string Url);
public record JoinChannelResult(bool Success, List<MessageDto> History, string? Error = null);
public record EmbedDto(
string? SiteName,
string? Title,
string? Description,
string? ImageAscii,
string Url);
string Url,
string? ThemeColor = null);
+17
View File
@@ -24,3 +24,20 @@ public record ChannelOperationResult(ChannelDto? Channel, ChannelError? Error, s
public static ChannelOperationResult Success(ChannelDto channel) => new(channel, null, null);
public static ChannelOperationResult Fail(ChannelError error, string message) => new(null, error, message);
}
public enum UserError
{
ValidationFailed,
AlreadyExists,
NotFound,
InvalidCredentials,
Banned
}
public record UserOperationResult(UserProfileDto? User, UserError? Error, string? ErrorMessage)
{
public bool IsSuccess => Error is null;
public static UserOperationResult Success(UserProfileDto user) => new(user, null, null);
public static UserOperationResult Fail(UserError error, string message) => new(null, error, message);
}
+1 -1
View File
@@ -31,7 +31,7 @@ public class IrcBroadcaster : IChatBroadcaster
}
}
public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
public async Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
{
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
@@ -44,8 +44,9 @@ public sealed class IrcClientConnection : IAsyncDisposable
public IrcClientConnection(TcpClient tcpClient, Stream stream)
{
_tcpClient = tcpClient;
_reader = new StreamReader(stream, Encoding.UTF8);
_writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true, NewLine = "\r\n" };
var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
_reader = new StreamReader(stream, utf8NoBom);
_writer = new StreamWriter(stream, utf8NoBom) { AutoFlush = true, NewLine = "\r\n" };
}
public async Task<string?> ReadLineAsync(CancellationToken ct)
+48 -13
View File
@@ -12,6 +12,7 @@ public sealed class IrcCommandHandler
private readonly IrcClientConnection _conn;
private readonly IrcOptions _options;
private readonly IChatService _chatService;
private readonly IUserService _userService;
private readonly IChannelService _channelService;
private readonly IMessageEncryptionService _encryption;
private readonly ILogger _logger;
@@ -22,6 +23,7 @@ public sealed class IrcCommandHandler
IrcClientConnection conn,
IrcOptions options,
IChatService chatService,
IUserService userService,
IChannelService channelService,
IMessageEncryptionService encryption,
ILogger logger)
@@ -29,6 +31,7 @@ public sealed class IrcCommandHandler
_conn = conn;
_options = options;
_chatService = chatService;
_userService = userService;
_channelService = channelService;
_encryption = encryption;
_logger = logger;
@@ -44,6 +47,8 @@ public sealed class IrcCommandHandler
line = line.TrimEnd('\r', '\n');
if (string.IsNullOrWhiteSpace(line)) continue;
_logger.LogDebug("IRC < {Id}: {Line}", _conn.ConnectionId, line);
var msg = IrcMessage.Parse(line);
try
@@ -138,6 +143,15 @@ public sealed class IrcCommandHandler
return;
}
// AUTHENTICATE * = client aborts SASL
if (msg.Parameters[0] == "*")
{
_conn.IsSasl = false;
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
":SASL authentication aborted");
return;
}
try
{
var decoded = Convert.FromBase64String(msg.Parameters[0]);
@@ -154,26 +168,39 @@ public sealed class IrcCommandHandler
var username = (parts[1].Length > 0 ? parts[1] : parts[0]).ToLowerInvariant();
var password = parts[2];
var result = await _chatService.AuthenticateUserAsync(username, password);
_logger.LogDebug("SASL PLAIN auth attempt for user '{Username}' (connection {Id})",
username, _conn.ConnectionId);
if (result is null)
var result = await _userService.AuthenticateUserAsync(username, password);
// Auth failed — try registering a new account
if (!result.IsSuccess)
result = await _userService.RegisterUserAsync(username, password);
if (!result.IsSuccess)
{
_logger.LogWarning("SASL auth/register failed for user '{Username}': {Error} (connection {Id})",
username, result.ErrorMessage, _conn.ConnectionId);
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
":SASL authentication failed");
$":SASL authentication failed — {result.ErrorMessage}");
return;
}
_conn.Nickname = result.Value.Username;
_conn.UserId = result.Value.UserId;
_conn.Nickname = result.User!.Username;
_conn.UserId = result.User!.Id;
_conn.IsAuthenticated = true;
_logger.LogInformation("SASL auth succeeded for user '{Username}' (connection {Id})",
username, _conn.ConnectionId);
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LOGGEDIN,
$"{_conn.Hostmask} {username} :You are now logged in as {username}");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_SASLSUCCESS,
":SASL authentication successful");
}
catch
catch (Exception ex)
{
_logger.LogError(ex, "SASL auth exception for connection {Id}", _conn.ConnectionId);
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
":SASL authentication failed");
}
@@ -240,6 +267,10 @@ public sealed class IrcCommandHandler
private async Task TryCompleteRegistrationAsync()
{
_logger.LogDebug("TryCompleteRegistration: CapNeg={Cap}, Registered={Reg}, Authenticated={Auth}, Nick={Nick}, User={User}, Id={Id}",
_conn.CapNegotiating, _conn.IsRegistered, _conn.IsAuthenticated,
_conn.Nickname, _conn.Username, _conn.ConnectionId);
if (_conn.CapNegotiating || _conn.IsRegistered) return;
// SASL already authenticated
@@ -260,22 +291,26 @@ public sealed class IrcCommandHandler
return;
}
var result = await _chatService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password);
var result = await _userService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password);
if (result is null)
// Auth failed — try registering a new account
if (!result.IsSuccess)
result = await _userService.RegisterUserAsync(_conn.Nickname!, _conn.Password);
if (!result.IsSuccess)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH,
":Password incorrect or account not found. Register via the EchoHub client first.");
$":{result.ErrorMessage}");
await _conn.SendAsync("ERROR :Authentication failed");
return;
}
_conn.UserId = result.Value.UserId;
_conn.Nickname = result.Value.Username;
_conn.UserId = result.User!.Id;
_conn.Nickname = result.User!.Username;
_conn.IsAuthenticated = true;
_conn.IsRegistered = true;
await _chatService.UserConnectedAsync(_conn.ConnectionId, result.Value.UserId, result.Value.Username);
await _chatService.UserConnectedAsync(_conn.ConnectionId, result.User!.Id, result.User!.Username);
await SendWelcomeBurstAsync();
}
@@ -527,7 +562,7 @@ public sealed class IrcCommandHandler
if (msg.Parameters.Count < 1) return;
var nick = msg.Parameters[^1].ToLowerInvariant();
var profile = await _chatService.GetUserProfileAsync(nick);
var profile = await _userService.GetUserProfileAsync(nick);
if (profile is null)
{
+2 -1
View File
@@ -120,10 +120,11 @@ public sealed class IrcGatewayService : BackgroundService
try
{
chatService = _services.GetRequiredService<IChatService>();
var userService = _services.GetRequiredService<IUserService>();
var channelService = _services.GetRequiredService<IChannelService>();
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
var handler = new IrcCommandHandler(
connection, _options, chatService, channelService, encryption, _logger);
connection, _options, chatService, userService, channelService, encryption, _logger);
await handler.RunAsync(ct);
}
+6
View File
@@ -0,0 +1,6 @@
bin/
obj/
.vs/
*.user
*.suo
*.DotSettings.user
@@ -2,6 +2,7 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Microsoft.IdentityModel.Tokens;
@@ -51,6 +52,31 @@ public class JwtTokenService
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
}
public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(UserProfileDto profile)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var expiresAt = DateTimeOffset.UtcNow.Add(AccessTokenLifetime);
Claim[] claims =
[
new(JwtRegisteredClaimNames.Sub, profile.Id.ToString()),
new("username", profile.Username),
new("display_name", profile.DisplayName ?? profile.Username),
new("role", profile.Role.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
];
var token = new JwtSecurityToken(
issuer: _issuer,
audience: _audience,
claims: claims,
expires: expiresAt.UtcDateTime,
signingCredentials: credentials);
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
}
public static string GenerateRefreshToken()
{
var randomBytes = new byte[64];
@@ -1,4 +1,4 @@
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Auth;
@@ -16,94 +16,59 @@ public class AuthController : ControllerBase
{
private readonly EchoHubDbContext _db;
private readonly JwtTokenService _jwt;
private readonly IUserService _userService;
public AuthController(EchoHubDbContext db, JwtTokenService jwt)
public AuthController(EchoHubDbContext db, JwtTokenService jwt, IUserService userService)
{
_db = db;
_jwt = jwt;
_userService = userService;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new ErrorResponse("Username and password are required."));
var result = await _userService.RegisterUserAsync(request.Username, request.Password, request.DisplayName);
if (!result.IsSuccess)
return MapUserError(result);
if (!ValidationConstants.UsernameRegex().IsMatch(request.Username))
return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens."));
if (request.Password.Length < 6)
return BadRequest(new ErrorResponse("Password must be at least 6 characters."));
if (request.Password.Length > ValidationConstants.MaxPasswordLength)
return BadRequest(new ErrorResponse($"Password must not exceed {ValidationConstants.MaxPasswordLength} characters."));
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername))
return Conflict(new ErrorResponse("Username is already taken."));
// First registered user on the server becomes the Owner
var isFirstUser = !await _db.Users.AnyAsync();
var user = new User
{
Id = Guid.NewGuid(),
Username = normalizedUsername,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
DisplayName = request.DisplayName?.Trim(),
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
};
_db.Users.Add(user);
await _db.SaveChangesAsync();
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
var profile = result.User!;
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(profile);
var refreshToken = JwtTokenService.GenerateRefreshToken();
_db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(refreshToken),
UserId = user.Id,
UserId = profile.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
});
await _db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, profile.Username, profile.DisplayName, profile.NicknameColor));
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new ErrorResponse("Username and password are required."));
var result = await _userService.AuthenticateUserAsync(request.Username, request.Password);
if (!result.IsSuccess)
return MapUserError(result);
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
return Unauthorized(new ErrorResponse("Invalid username or password."));
if (user.IsBanned)
return Unauthorized(new ErrorResponse("Your account has been banned."));
user.LastSeenAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync();
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
var profile = result.User!;
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(profile);
var refreshToken = JwtTokenService.GenerateRefreshToken();
_db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(refreshToken),
UserId = user.Id,
UserId = profile.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
});
await _db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, profile.Username, profile.DisplayName, profile.NicknameColor));
}
[HttpPost("refresh")]
@@ -159,4 +124,14 @@ public class AuthController : ControllerBase
return Ok();
}
private IActionResult MapUserError(UserOperationResult result) => result.Error switch
{
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
UserError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)),
UserError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)),
UserError.InvalidCredentials => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
UserError.Banned => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
_ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")),
};
}
@@ -1,12 +1,11 @@
using System.Security.Claims;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
@@ -16,25 +15,24 @@ namespace EchoHub.Server.Controllers;
[EnableRateLimiting("general")]
public class UsersController : ControllerBase
{
private readonly EchoHubDbContext _db;
private readonly IUserService _userService;
private readonly ImageToAsciiService _asciiService;
public UsersController(EchoHubDbContext db, ImageToAsciiService asciiService)
public UsersController(IUserService userService, ImageToAsciiService asciiService)
{
_db = db;
_userService = userService;
_asciiService = asciiService;
}
[HttpGet("{username}/profile")]
public async Task<IActionResult> GetProfile(string username)
{
var normalizedUsername = username.ToLowerInvariant().Trim();
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
var profile = await _userService.GetUserProfileAsync(username);
if (user is null)
if (profile is null)
return NotFound(new ErrorResponse("User not found."));
return Ok(ToProfileDto(user));
return Ok(profile);
}
[HttpPut("profile")]
@@ -44,37 +42,13 @@ public class UsersController : ControllerBase
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var user = await _db.Users.FindAsync(userId);
var result = await _userService.UpdateProfileAsync(
Guid.Parse(userIdClaim), request.DisplayName, request.Bio, request.NicknameColor);
if (user is null)
return NotFound(new ErrorResponse("User not found."));
if (!result.IsSuccess)
return MapUserError(result);
if (request.DisplayName is not null)
{
if (request.DisplayName.Length > ValidationConstants.MaxDisplayNameLength)
return BadRequest(new ErrorResponse($"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters."));
user.DisplayName = request.DisplayName.Trim();
}
if (request.Bio is not null)
{
if (request.Bio.Length > ValidationConstants.MaxBioLength)
return BadRequest(new ErrorResponse($"Bio must not exceed {ValidationConstants.MaxBioLength} characters."));
user.Bio = request.Bio.Trim();
}
if (request.NicknameColor is not null)
{
var color = request.NicknameColor.Trim();
if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color))
return BadRequest(new ErrorResponse("Nickname color must be a valid hex color (e.g. #FF5500)."));
user.NicknameColor = color.Length > 0 ? color : null;
}
await _db.SaveChangesAsync();
return Ok(ToProfileDto(user));
return Ok(result.User!);
}
[HttpPost("avatar")]
@@ -85,12 +59,6 @@ public class UsersController : ControllerBase
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var user = await _db.Users.FindAsync(userId);
if (user is null)
return NotFound(new ErrorResponse("User not found."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
return BadRequest(new ErrorResponse("No file uploaded."));
@@ -106,22 +74,20 @@ public class UsersController : ControllerBase
var asciiArt = _asciiService.ConvertToAscii(stream);
user.AvatarAscii = asciiArt;
await _db.SaveChangesAsync();
var result = await _userService.SetAvatarAsync(Guid.Parse(userIdClaim), asciiArt);
if (!result.IsSuccess)
return MapUserError(result);
return Ok(new AvatarUploadResponse(asciiArt));
}
private static UserProfileDto ToProfileDto(Core.Models.User user) => new(
user.Id,
user.Username,
user.DisplayName,
user.Bio,
user.NicknameColor,
user.AvatarAscii,
user.Status,
user.StatusMessage,
user.Role,
user.CreatedAt,
user.LastSeenAt);
private IActionResult MapUserError(UserOperationResult result) => result.Error switch
{
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
UserError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)),
UserError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)),
UserError.InvalidCredentials => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
UserError.Banned => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
_ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")),
};
}
+39
View File
@@ -0,0 +1,39 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy project files first for layer caching
COPY EchoHub.Core/EchoHub.Core.csproj EchoHub.Core/
COPY EchoHub.Server.Irc/EchoHub.Server.Irc.csproj EchoHub.Server.Irc/
COPY EchoHub.Server/EchoHub.Server.csproj EchoHub.Server/
COPY Directory.Build.props .
RUN dotnet restore EchoHub.Server/EchoHub.Server.csproj
# Copy everything and publish
COPY EchoHub.Core/ EchoHub.Core/
COPY EchoHub.Server.Irc/ EchoHub.Server.Irc/
COPY EchoHub.Server/ EchoHub.Server/
RUN dotnet publish EchoHub.Server/EchoHub.Server.csproj -c Release -o /app/publish --no-restore
# Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
RUN groupadd -r echohub && useradd -r -g echohub -d /app echohub \
&& mkdir -p /app/data \
&& chown -R echohub:echohub /app
COPY --from=build --chown=echohub:echohub /app/publish .
COPY --chown=echohub:echohub EchoHub.Server/docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
USER echohub
ENV ASPNETCORE_ENVIRONMENT=Production \
Urls=http://0.0.0.0:5000 \
ConnectionStrings__DefaultConnection="Data Source=/app/data/echohub.db" \
Storage__Path=/app/data/uploads \
Serilog__WriteTo__1__Args__path=/app/data/logs/echohub-server-.log
EXPOSE 5000 6667 6697
ENTRYPOINT ["/app/docker-entrypoint.sh"]
+4 -8
View File
@@ -56,7 +56,7 @@ public class ChatHub : Hub<IEchoHubClient>
}
}
public async Task<List<MessageDto>> JoinChannel(string channelName)
public async Task<JoinChannelResult> JoinChannel(string channelName)
{
try
{
@@ -64,19 +64,15 @@ public class ChatHub : Hub<IEchoHubClient>
Context.ConnectionId, CurrentUserId, CurrentUsername, channelName);
if (error is not null)
{
await Clients.Caller.Error(error);
return [];
}
return new JoinChannelResult(false, [], error);
await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim());
return history;
return new JoinChannelResult(true, history);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error joining channel '{Channel}' for {User}", channelName, CurrentUsername);
await Clients.Caller.Error($"Failed to join channel: {ex.Message}");
return [];
return new JoinChannelResult(false, [], $"Failed to join channel: {ex.Message}");
}
}
+2
View File
@@ -109,12 +109,14 @@ while (true)
builder.Services.AddSingleton<LinkEmbedService>();
builder.Services.AddHostedService<ServerDirectoryService>();
builder.Services.AddHostedService<FileCleanupService>();
builder.Services.AddHostedService<MuteExpirationService>();
// ── Encryption ─────────────────────────────────────────────────────
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
// ── Chat Service + Broadcasters ─────────────────────────────────────
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
builder.Services.AddSingleton<IUserService, UserService>();
builder.Services.AddSingleton<IChannelService, ChannelService>();
builder.Services.AddSingleton<IChatService, ChatService>();
+26 -34
View File
@@ -107,7 +107,31 @@ public class ChatService : IChatService
if (isNewJoin)
{
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId));
// Fetch presence data so clients can update their lists incrementally
UserPresenceDto? presence = null;
try
{
using var presenceScope = _scopeFactory.CreateScope();
var presenceDb = presenceScope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await presenceDb.Users.FindAsync(userId);
if (user is not null)
{
presence = new UserPresenceDto(
user.Username, user.DisplayName, user.NicknameColor,
user.Status, user.StatusMessage, user.Role);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to fetch presence for {User} on join", username);
}
// Don't broadcast join for invisible users — they still get history but stay hidden
if (presence is null || presence.Status != UserStatus.Invisible)
{
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId));
}
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
}
@@ -272,7 +296,7 @@ public class ChatService : IChatService
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await db.Users
.Where(u => onlineUsernames.Contains(u.Username))
.Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible)
.Select(u => new UserPresenceDto(
u.Username,
u.DisplayName,
@@ -304,41 +328,9 @@ public class ChatService : IChatService
}
}
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
{
username = username.ToLowerInvariant();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user is null) return null;
return new UserProfileDto(
user.Id, user.Username, user.DisplayName, user.Bio,
user.NicknameColor, user.AvatarAscii, user.Status,
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
}
public Task<List<string>> GetChannelsForUserAsync(string username)
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));
public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password)
{
username = username.ToLowerInvariant();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user is null) return null;
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
return null;
return (user.Id, user.Username);
}
/// <summary>
/// Collapse consecutive newlines and cap total line count to prevent newline spam.
/// </summary>
@@ -108,9 +108,47 @@ public partial class LinkEmbedService
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
description = description is not null ? WebUtility.HtmlDecode(description) : null;
return new EmbedDto(siteName, title, description, null, url);
// Extract theme-color meta tag for embed border color
var themeColor = ParseThemeColor(html);
return new EmbedDto(siteName, title, description, null, url, themeColor);
}
private static string? ParseThemeColor(string html)
{
// ThemeColorRegex: group 3 = color value
var match = ThemeColorRegex().Match(html);
var color = match.Success ? match.Groups[3].Value.Trim() : null;
if (color is null)
{
// ThemeColorReversedRegex: group 2 = color value
match = ThemeColorReversedRegex().Match(html);
color = match.Success ? match.Groups[2].Value.Trim() : null;
}
if (color is null)
return null;
if (color.Length == 4 && color[0] == '#'
&& IsHexDigit(color[1]) && IsHexDigit(color[2]) && IsHexDigit(color[3]))
{
// Expand #RGB to #RRGGBB
return $"#{color[1]}{color[1]}{color[2]}{color[2]}{color[3]}{color[3]}";
}
if (color.Length == 7 && color[0] == '#'
&& color[1..].All(IsHexDigit))
{
return color;
}
return null;
}
private static bool IsHexDigit(char c) =>
c is (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F');
private static List<string> ExtractUrls(string content)
{
var urls = new List<string>();
@@ -213,4 +251,14 @@ public partial class LinkEmbedService
[GeneratedRegex(@"<title[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex TitleTagRegex();
// <meta name="theme-color" content="#hex">
[GeneratedRegex(@"<meta\s+[^>]*?name\s*=\s*([""'])theme-color\1[^>]*?content\s*=\s*([""'])(.*?)\2[^>]*/?>",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
private static partial Regex ThemeColorRegex();
// <meta content="#hex" name="theme-color">
[GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*([""'])(.*?)\1[^>]*?name\s*=\s*([""'])theme-color\3[^>]*/?>",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
private static partial Regex ThemeColorReversedRegex();
}
@@ -0,0 +1,63 @@
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Services;
/// <summary>
/// Background service that periodically unmutes users whose timed mute has expired.
/// </summary>
public sealed class MuteExpirationService : BackgroundService
{
private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(15);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<MuteExpirationService> _logger;
public MuteExpirationService(IServiceScopeFactory scopeFactory, ILogger<MuteExpirationService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Yield();
while (!stoppingToken.IsCancellationRequested)
{
try
{
await UnmuteExpiredUsersAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Error checking mute expirations");
}
await Task.Delay(CheckInterval, stoppingToken);
}
}
private async Task UnmuteExpiredUsersAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var now = DateTimeOffset.UtcNow;
var expired = await db.Users
.Where(u => u.IsMuted && u.MutedUntil.HasValue && u.MutedUntil.Value <= now)
.ToListAsync(ct);
if (expired.Count == 0)
return;
foreach (var user in expired)
{
user.IsMuted = false;
user.MutedUntil = null;
_logger.LogInformation("Auto-unmuted user {Username} (timed mute expired)", user.Username);
}
await db.SaveChangesAsync(ct);
}
}
@@ -14,6 +14,8 @@ public class PresenceTracker
{
_connections[connectionId] = (userId, username);
// Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside.
// It also makes the TryGetValue → add sequence atomic to prevent race conditions.
lock (_lock)
{
if (!_userConnections.TryGetValue(username, out var connections))
@@ -170,6 +170,9 @@ public sealed class ServerDirectoryService : BackgroundService
var currentCount = _presenceTracker.GetOnlineUserCount();
if (currentCount == _lastReportedUserCount)
continue;
try
{
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
@@ -23,12 +23,12 @@ public class SignalRBroadcaster : IChatBroadcaster
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
=> HubContext.Clients.Group(channelName).ReceiveMessage(message);
public Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
public Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
{
if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-"))
return HubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username);
return HubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username, presence);
return HubContext.Clients.Group(channelName).UserJoined(channelName, username);
return HubContext.Clients.Group(channelName).UserJoined(channelName, username, presence);
}
public Task SendUserLeftAsync(string channelName, string username)
+172
View File
@@ -0,0 +1,172 @@
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace EchoHub.Server.Services;
public class UserService : IUserService
{
private readonly IServiceScopeFactory _scopeFactory;
public UserService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required.");
if (!ValidationConstants.UsernameRegex().IsMatch(username))
return UserOperationResult.Fail(UserError.ValidationFailed,
"Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens.");
if (password.Length < 6)
return UserOperationResult.Fail(UserError.ValidationFailed, "Password must be at least 6 characters.");
if (password.Length > ValidationConstants.MaxPasswordLength)
return UserOperationResult.Fail(UserError.ValidationFailed,
$"Password must not exceed {ValidationConstants.MaxPasswordLength} characters.");
var normalizedUsername = username.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
return UserOperationResult.Fail(UserError.AlreadyExists, "Username is already taken.");
var isFirstUser = !await db.Users.AnyAsync();
var user = new User
{
Id = Guid.NewGuid(),
Username = normalizedUsername,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
DisplayName = displayName?.Trim(),
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
};
db.Users.Add(user);
await db.SaveChangesAsync();
return UserOperationResult.Success(ToProfileDto(user));
}
public async Task<UserOperationResult> AuthenticateUserAsync(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required.");
var normalizedUsername = username.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
if (user is null || !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
return UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password.");
if (user.IsBanned)
return UserOperationResult.Fail(UserError.Banned, "Your account has been banned.");
user.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return UserOperationResult.Success(ToProfileDto(user));
}
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
{
var normalizedUsername = username.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
return user is null ? null : ToProfileDto(user);
}
public async Task<UserProfileDto?> GetUserByIdAsync(Guid userId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FindAsync(userId);
return user is null ? null : ToProfileDto(user);
}
public async Task<UserOperationResult> UpdateProfileAsync(
Guid userId, string? displayName, string? bio, string? nicknameColor)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FindAsync(userId);
if (user is null)
return UserOperationResult.Fail(UserError.NotFound, "User not found.");
if (displayName is not null)
{
if (displayName.Length > ValidationConstants.MaxDisplayNameLength)
return UserOperationResult.Fail(UserError.ValidationFailed,
$"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters.");
user.DisplayName = displayName.Trim();
}
if (bio is not null)
{
if (bio.Length > ValidationConstants.MaxBioLength)
return UserOperationResult.Fail(UserError.ValidationFailed,
$"Bio must not exceed {ValidationConstants.MaxBioLength} characters.");
user.Bio = bio.Trim();
}
if (nicknameColor is not null)
{
var color = nicknameColor.Trim();
if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color))
return UserOperationResult.Fail(UserError.ValidationFailed,
"Nickname color must be a valid hex color (e.g. #FF5500).");
user.NicknameColor = color.Length > 0 ? color : null;
}
await db.SaveChangesAsync();
return UserOperationResult.Success(ToProfileDto(user));
}
public async Task<UserOperationResult> SetAvatarAsync(Guid userId, string asciiArt)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FindAsync(userId);
if (user is null)
return UserOperationResult.Fail(UserError.NotFound, "User not found.");
user.AvatarAscii = asciiArt;
await db.SaveChangesAsync();
return UserOperationResult.Success(ToProfileDto(user));
}
private static UserProfileDto ToProfileDto(User user) => new(
user.Id,
user.Username,
user.DisplayName,
user.Bio,
user.NicknameColor,
user.AvatarAscii,
user.Status,
user.StatusMessage,
user.Role,
user.CreatedAt,
user.LastSeenAt);
}
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Persist appsettings.json in the data volume so auto-generated keys
# (JWT secret, encryption key) survive container recreation.
if [ ! -f /app/data/appsettings.json ]; then
cp /app/appsettings.example.json /app/data/appsettings.json
fi
ln -sf /app/data/appsettings.json /app/appsettings.json
exec dotnet EchoHub.Server.dll
+2 -2
View File
@@ -132,7 +132,7 @@ public class IrcBroadcasterTests
{
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
await _broadcaster.SendUserJoinedAsync("general", "alice");
await _broadcaster.SendUserJoinedAsync("general", "alice", null);
var output = bobStream.GetOutputLines();
Assert.Contains(output, l => l.Contains("JOIN #general") && l.Contains("alice"));
@@ -144,7 +144,7 @@ public class IrcBroadcasterTests
var (conn, excludedStream) = AddConnectionWithCapture("alice", "general");
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
await _broadcaster.SendUserJoinedAsync("general", "alice", conn.ConnectionId);
await _broadcaster.SendUserJoinedAsync("general", "alice", null, conn.ConnectionId);
// Excluded connection should not get the message
Assert.Empty(excludedStream.GetOutputLines());
@@ -12,11 +12,12 @@ public class IrcCommandHandlerTests
{
private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null };
private readonly FakeChatService _chatService = new();
private readonly FakeUserService _userService = new();
private readonly FakeChannelService _channelService = new();
private readonly FakeEncryptionService _encryption = new();
private IrcCommandHandler CreateHandler(IrcClientConnection conn) =>
new(conn, _options, _chatService, _channelService, _encryption, NullLogger.Instance);
new(conn, _options, _chatService, _userService, _channelService, _encryption, NullLogger.Instance);
private async Task<List<string>> RunAndCapture(string[] inputLines,
Action<IrcClientConnection>? setup = null)
@@ -85,7 +86,7 @@ public class IrcCommandHandlerTests
public async Task PassNickUser_ValidCredentials_Registers()
{
var userId = Guid.NewGuid();
_chatService.AuthResult = (userId, "alice");
_userService.AuthResult = FakeUserService.SuccessResult(userId, "alice");
var lines = await RunAndCapture([
"PASS secret123",
@@ -112,7 +113,7 @@ public class IrcCommandHandlerTests
[Fact]
public async Task PassNickUser_WrongPassword_GetsAuthError()
{
_chatService.AuthResult = null;
_userService.AuthResult = null;
var lines = await RunAndCapture([
"PASS wrongpassword",
@@ -120,7 +121,8 @@ public class IrcCommandHandlerTests
"USER alice 0 * :Alice Smith"
]);
Assert.Contains(lines, l => l.Contains("464") && l.Contains("incorrect"));
Assert.Contains(lines, l => l.Contains("464"));
Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Authentication failed"));
}
[Fact]
@@ -189,7 +191,7 @@ public class IrcCommandHandlerTests
public async Task SaslPlain_ValidCredentials_Authenticates()
{
var userId = Guid.NewGuid();
_chatService.AuthResult = (userId, "alice");
_userService.AuthResult = FakeUserService.SuccessResult(userId, "alice");
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0password123"));
@@ -210,7 +212,7 @@ public class IrcCommandHandlerTests
[Fact]
public async Task SaslPlain_InvalidCredentials_GetsError()
{
_chatService.AuthResult = null;
_userService.AuthResult = null;
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0wrongpwd"));
@@ -478,7 +480,7 @@ public class IrcCommandHandlerTests
[Fact]
public async Task Whois_ExistingUser_ReturnsInfo()
{
_chatService.ProfileToReturn = new UserProfileDto(
_userService.ProfileToReturn = new UserProfileDto(
Guid.NewGuid(), "bob", "Bob S.", "Hello!", null, null,
UserStatus.Online, null, ServerRole.Member,
DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow);
@@ -496,7 +498,7 @@ public class IrcCommandHandlerTests
[Fact]
public async Task Whois_NonexistentUser_GetsNoSuchNickError()
{
_chatService.ProfileToReturn = null;
_userService.ProfileToReturn = null;
var lines = await RunAuthenticated(["WHOIS ghost"]);
@@ -506,7 +508,7 @@ public class IrcCommandHandlerTests
[Fact]
public async Task Whois_AwayUser_ShowsAwayMessage()
{
_chatService.ProfileToReturn = new UserProfileDto(
_userService.ProfileToReturn = new UserProfileDto(
Guid.NewGuid(), "bob", null, null, null, null,
UserStatus.Away, "Gone fishing", ServerRole.Member,
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow);
+40 -8
View File
@@ -156,8 +156,6 @@ internal sealed class FakeChatService : IChatService
public List<MessageDto> HistoryToReturn { get; set; } = [];
public string? JoinError { get; set; }
public string? SendMessageError { get; set; }
public (Guid UserId, string Username)? AuthResult { get; set; }
public UserProfileDto? ProfileToReturn { get; set; }
public List<string> ChannelsForUserToReturn { get; set; } = [];
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
@@ -210,14 +208,8 @@ internal sealed class FakeChatService : IChatService
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) =>
Task.CompletedTask;
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
Task.FromResult(ProfileToReturn);
public Task<List<string>> GetChannelsForUserAsync(string username) =>
Task.FromResult(ChannelsForUserToReturn);
public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) =>
Task.FromResult(AuthResult);
}
/// <summary>
@@ -258,3 +250,43 @@ internal sealed class FakeChannelService : IChannelService
public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
Task.FromResult(MembershipResult);
}
/// <summary>
/// Fake user service that records method calls and returns pre-configured results.
/// </summary>
internal sealed class FakeUserService : IUserService
{
// Configurable results
public UserOperationResult? AuthResult { get; set; }
public UserOperationResult? RegisterResult { get; set; }
public UserProfileDto? ProfileToReturn { get; set; }
/// <summary>
/// Helper to create a success result from a simple userId + username pair.
/// </summary>
public static UserOperationResult SuccessResult(Guid userId, string username) =>
UserOperationResult.Success(new UserProfileDto(
userId, username, null, null, null, null,
UserStatus.Online, null, ServerRole.Member,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow));
public Task<UserOperationResult> AuthenticateUserAsync(string username, string password) =>
Task.FromResult(AuthResult
?? UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password."));
public Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null) =>
Task.FromResult(RegisterResult
?? UserOperationResult.Fail(UserError.AlreadyExists, "Username is already taken."));
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
Task.FromResult(ProfileToReturn);
public Task<UserProfileDto?> GetUserByIdAsync(Guid userId) =>
Task.FromResult(ProfileToReturn);
public Task<UserOperationResult> UpdateProfileAsync(Guid userId, string? displayName, string? bio, string? nicknameColor) =>
Task.FromResult(UserOperationResult.Fail(UserError.NotFound, "Not configured"));
public Task<UserOperationResult> SetAvatarAsync(Guid userId, string asciiArt) =>
Task.FromResult(UserOperationResult.Fail(UserError.NotFound, "Not configured"));
}