From 993bb1f97331051edc423d499c210a85e8ed3e9c Mon Sep 17 00:00:00 2001 From: HueByte Date: Sun, 22 Feb 2026 16:43:05 +0100 Subject: [PATCH 01/30] refactor: enhance audio playback service with semaphore locking for thread safety --- .../Services/AudioPlaybackService.cs | 28 ++++++- .../UI/Dialogs/AudioPlayerDialog.cs | 81 ++++++++++++------- 2 files changed, 81 insertions(+), 28 deletions(-) diff --git a/src/EchoHub.Client/Services/AudioPlaybackService.cs b/src/EchoHub.Client/Services/AudioPlaybackService.cs index 7213ae7..6f819fd 100644 --- a/src/EchoHub.Client/Services/AudioPlaybackService.cs +++ b/src/EchoHub.Client/Services/AudioPlaybackService.cs @@ -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(); + } } } diff --git a/src/EchoHub.Client/UI/Dialogs/AudioPlayerDialog.cs b/src/EchoHub.Client/UI/Dialogs/AudioPlayerDialog.cs index 7c8e81c..45db6e6 100644 --- a/src/EchoHub.Client/UI/Dialogs/AudioPlayerDialog.cs +++ b/src/EchoHub.Client/UI/Dialogs/AudioPlayerDialog.cs @@ -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; }); }); }; From c5be63db251d47cd3ef534593380721a3852f7c5 Mon Sep 17 00:00:00 2001 From: HueByte Date: Sun, 22 Feb 2026 17:58:18 +0100 Subject: [PATCH 02/30] feat: add Docker support for EchoHub.Server with Dockerfile and docker-compose.yml --- docker-compose.yml | 23 ++++++++++++++++++++ docs/changelog/v0.2.7.md | 4 ++++ src/EchoHub.Server/.dockerignore | 6 ++++++ src/EchoHub.Server/Dockerfile | 37 ++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 docker-compose.yml create mode 100644 src/EchoHub.Server/.dockerignore create mode 100644 src/EchoHub.Server/Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..67fdd99 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +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 gateway (enable Irc__Enabled=true first) + # - "6697:6697" # IRC TLS + volumes: + - echohub-data:/app/data + environment: + - Server__Name=My EchoHub Server + - Server__Description=A self-hosted EchoHub chat server + # - Server__PublicServer=true + # - Server__PublicHost=echohub.example.com + # - Irc__Enabled=true + +volumes: + echohub-data: diff --git a/docs/changelog/v0.2.7.md b/docs/changelog/v0.2.7.md index 929c70c..687a90f 100644 --- a/docs/changelog/v0.2.7.md +++ b/docs/changelog/v0.2.7.md @@ -4,6 +4,10 @@ - Fix user list empty on initial connect — `FetchAndUpdateOnlineUsers` was called before `InvokeUI` set the current channel, causing an early return +## New Features + +- Add Docker support for EchoHub.Server — `docker compose up -d` for easy self-hosting with persistent volume for database, uploads, and logs + ## Infrastructure - Switch Terminal.Gui from local fork submodule back to NuGet package (`2.0.0-develop.5039`) — transparent color PR merged upstream diff --git a/src/EchoHub.Server/.dockerignore b/src/EchoHub.Server/.dockerignore new file mode 100644 index 0000000..f6cf303 --- /dev/null +++ b/src/EchoHub.Server/.dockerignore @@ -0,0 +1,6 @@ +bin/ +obj/ +.vs/ +*.user +*.suo +*.DotSettings.user diff --git a/src/EchoHub.Server/Dockerfile b/src/EchoHub.Server/Dockerfile new file mode 100644 index 0000000..2a71380 --- /dev/null +++ b/src/EchoHub.Server/Dockerfile @@ -0,0 +1,37 @@ +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 . + +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 ["dotnet", "EchoHub.Server.dll"] From 039390c8b044e72266c426b4c700c27c325c9d22 Mon Sep 17 00:00:00 2001 From: HueByte Date: Sun, 22 Feb 2026 18:03:25 +0100 Subject: [PATCH 03/30] feat: add Docker workflow for building and pushing multi-arch server image to GHCR --- .github/workflows/docker.yml | 92 ++++++++++++++++++++++++++++++++++++ docker-compose.yml | 4 +- docs/changelog/v0.2.7.md | 4 ++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..b5870e9 --- /dev/null +++ b/.github/workflows/docker.yml @@ -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 '(?<=)[^<]+' 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." diff --git a/docker-compose.yml b/docker-compose.yml index 67fdd99..8135ebc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,8 @@ services: restart: unless-stopped ports: - "5000:5000" - # - "6667:6667" # IRC gateway (enable Irc__Enabled=true first) - # - "6697:6697" # IRC TLS + # - "6667:6667" # IRC (plain, no encryption) + # - "6697:6697" # IRC (TLS encrypted, preferred) volumes: - echohub-data:/app/data environment: diff --git a/docs/changelog/v0.2.7.md b/docs/changelog/v0.2.7.md index 687a90f..198ccfe 100644 --- a/docs/changelog/v0.2.7.md +++ b/docs/changelog/v0.2.7.md @@ -8,6 +8,10 @@ - Add Docker support for EchoHub.Server — `docker compose up -d` for easy self-hosting with persistent volume for database, uploads, and logs +## CI + +- Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release + ## Infrastructure - Switch Terminal.Gui from local fork submodule back to NuGet package (`2.0.0-develop.5039`) — transparent color PR merged upstream From 45fd382b134fee2e320d8da6dcddb1dc14a96275 Mon Sep 17 00:00:00 2001 From: HueByte Date: Sun, 22 Feb 2026 18:12:06 +0100 Subject: [PATCH 04/30] feat: add Docker support with environment configuration and entrypoint script --- .env.example | 39 +++++++ docker-compose.yml | 8 +- docs/articles/docker.md | 130 ++++++++++++++++++++++++ docs/articles/getting-started.md | 11 ++ docs/articles/toc.yml | 2 + src/EchoHub.Server/Dockerfile | 4 +- src/EchoHub.Server/docker-entrypoint.sh | 10 ++ 7 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 .env.example create mode 100644 docs/articles/docker.md create mode 100644 src/EchoHub.Server/docker-entrypoint.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..749bb9b --- /dev/null +++ b/.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 8135ebc..ca887c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,12 +12,8 @@ services: # - "6697:6697" # IRC (TLS encrypted, preferred) volumes: - echohub-data:/app/data - environment: - - Server__Name=My EchoHub Server - - Server__Description=A self-hosted EchoHub chat server - # - Server__PublicServer=true - # - Server__PublicHost=echohub.example.com - # - Irc__Enabled=true + env_file: + - .env volumes: echohub-data: diff --git a/docs/articles/docker.md b/docs/articles/docker.md new file mode 100644 index 0000000..7eaaa19 --- /dev/null +++ b/docs/articles/docker.md @@ -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`: + +``` +/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/`. diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md index 2d45cad..7b7bba3 100644 --- a/docs/articles/getting-started.md +++ b/docs/articles/getting-started.md @@ -6,6 +6,17 @@ Or grab a self-contained binary from [Releases](https://github.com/HueByte/EchoHub/releases) -- no runtime needed. +## 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. + ## Run the Server ```bash diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index 814359c..b371d95 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -1,5 +1,7 @@ - name: Getting Started href: getting-started.md +- name: Docker + href: docker.md - name: Architecture href: architecture.md - name: Encryption diff --git a/src/EchoHub.Server/Dockerfile b/src/EchoHub.Server/Dockerfile index 2a71380..c4b94a9 100644 --- a/src/EchoHub.Server/Dockerfile +++ b/src/EchoHub.Server/Dockerfile @@ -23,6 +23,8 @@ RUN groupadd -r echohub && useradd -r -g echohub -d /app echohub \ && 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 @@ -34,4 +36,4 @@ ENV ASPNETCORE_ENVIRONMENT=Production \ EXPOSE 5000 6667 6697 -ENTRYPOINT ["dotnet", "EchoHub.Server.dll"] +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/src/EchoHub.Server/docker-entrypoint.sh b/src/EchoHub.Server/docker-entrypoint.sh new file mode 100644 index 0000000..902c86d --- /dev/null +++ b/src/EchoHub.Server/docker-entrypoint.sh @@ -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 From c2a8e9cfc88cd378a8a74ede6f5d5fe8c5f2e5fb Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 10:40:56 +0100 Subject: [PATCH 05/30] feat: enhance SASL authentication logging for better traceability --- src/EchoHub.Server.Irc/IrcCommandHandler.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 4d1fa30..f84a9f0 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -154,10 +154,15 @@ public sealed class IrcCommandHandler var username = (parts[1].Length > 0 ? parts[1] : parts[0]).ToLowerInvariant(); var password = parts[2]; + _logger.LogDebug("SASL PLAIN auth attempt for user '{Username}' (connection {Id})", + username, _conn.ConnectionId); + var result = await _chatService.AuthenticateUserAsync(username, password); if (result is null) { + _logger.LogWarning("SASL auth failed for user '{Username}' (connection {Id})", + username, _conn.ConnectionId); await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL, ":SASL authentication failed"); return; @@ -167,13 +172,17 @@ public sealed class IrcCommandHandler _conn.UserId = result.Value.UserId; _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 +249,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 From 3c760b0bd87649549536e5672d0bb090692a5120 Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 10:54:02 +0100 Subject: [PATCH 06/30] feat: improve IRC command handling with debug logging for incoming messages --- src/EchoHub.Server.Irc/IrcClientConnection.cs | 5 +++-- src/EchoHub.Server.Irc/IrcCommandHandler.cs | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/EchoHub.Server.Irc/IrcClientConnection.cs b/src/EchoHub.Server.Irc/IrcClientConnection.cs index bbce314..3b1b8e4 100644 --- a/src/EchoHub.Server.Irc/IrcClientConnection.cs +++ b/src/EchoHub.Server.Irc/IrcClientConnection.cs @@ -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 ReadLineAsync(CancellationToken ct) diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index f84a9f0..baf53e9 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -44,6 +44,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 From 28c4b2993f60bd9efa596d129084a5033de42d31 Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 10:57:12 +0100 Subject: [PATCH 07/30] feat: handle SASL authentication abort scenario in IrcCommandHandler --- src/EchoHub.Server.Irc/IrcCommandHandler.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index baf53e9..f2f6a0f 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -140,6 +140,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]); From 27a25b1b436d2f73ba4818b74fc563e8c3d6bb56 Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 14:51:04 +0100 Subject: [PATCH 08/30] feat: implement user registration and update SASL authentication handling --- docs/changelog/index.md | 3 ++ docs/changelog/toc.yml | 2 ++ docs/changelog/v0.2.7.md | 8 ------ docs/changelog/v0.2.8.md | 15 ++++++++++ src/Directory.Build.props | 2 +- src/EchoHub.Core/Contracts/IChatService.cs | 1 + src/EchoHub.Server.Irc/IrcCommandHandler.cs | 12 ++++++-- src/EchoHub.Server/Services/ChatService.cs | 32 +++++++++++++++++++++ 8 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 docs/changelog/v0.2.8.md diff --git a/docs/changelog/index.md b/docs/changelog/index.md index f35822f..d589a66 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -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 diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index 7f71eca..7bc9ec0 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,5 +1,7 @@ - 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 diff --git a/docs/changelog/v0.2.7.md b/docs/changelog/v0.2.7.md index 198ccfe..929c70c 100644 --- a/docs/changelog/v0.2.7.md +++ b/docs/changelog/v0.2.7.md @@ -4,14 +4,6 @@ - Fix user list empty on initial connect — `FetchAndUpdateOnlineUsers` was called before `InvokeUI` set the current channel, causing an early return -## New Features - -- Add Docker support for EchoHub.Server — `docker compose up -d` for easy self-hosting with persistent volume for database, uploads, and logs - -## CI - -- Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release - ## Infrastructure - Switch Terminal.Gui from local fork submodule back to NuGet package (`2.0.0-develop.5039`) — transparent color PR merged upstream diff --git a/docs/changelog/v0.2.8.md b/docs/changelog/v0.2.8.md new file mode 100644 index 0000000..7ffff4b --- /dev/null +++ b/docs/changelog/v0.2.8.md @@ -0,0 +1,15 @@ +# v0.2.8 + +## Bug Fixes + +- 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 + +## 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) + +## CI + +- Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 31d2558..744d01d 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.7 + 0.2.8 true $(NoWarn);CS1591 diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index 03ea1c2..97d880a 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -29,4 +29,5 @@ public interface IChatService Task GetUserProfileAsync(string username); Task> GetChannelsForUserAsync(string username); Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password); + Task<(Guid UserId, string Username)?> RegisterUserAsync(string username, string password); } diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index f2f6a0f..00b4370 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -170,9 +170,13 @@ public sealed class IrcCommandHandler var result = await _chatService.AuthenticateUserAsync(username, password); + // Auth failed — try registering a new account + if (result is null) + result = await _chatService.RegisterUserAsync(username, password); + if (result is null) { - _logger.LogWarning("SASL auth failed for user '{Username}' (connection {Id})", + _logger.LogWarning("SASL auth/register failed for user '{Username}' (connection {Id})", username, _conn.ConnectionId); await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL, ":SASL authentication failed"); @@ -286,10 +290,14 @@ public sealed class IrcCommandHandler var result = await _chatService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password); + // Auth failed — try registering a new account + if (result is null) + result = await _chatService.RegisterUserAsync(_conn.Nickname!, _conn.Password); + if (result is null) { await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH, - ":Password incorrect or account not found. Register via the EchoHub client first."); + ":Password incorrect."); await _conn.SendAsync("ERROR :Authentication failed"); return; } diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index b5010fd..63954c1 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -339,6 +339,38 @@ public class ChatService : IChatService return (user.Id, user.Username); } + public async Task<(Guid UserId, string Username)?> RegisterUserAsync(string username, string password) + { + username = username.ToLowerInvariant().Trim(); + + if (!ValidationConstants.UsernameRegex().IsMatch(username)) + return null; + + if (password.Length < 6 || password.Length > ValidationConstants.MaxPasswordLength) + return null; + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (await db.Users.AnyAsync(u => u.Username == username)) + return null; + + var isFirstUser = !await db.Users.AnyAsync(); + + var user = new User + { + Id = Guid.NewGuid(), + Username = username, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), + Role = isFirstUser ? ServerRole.Owner : ServerRole.Member, + }; + + db.Users.Add(user); + await db.SaveChangesAsync(); + + return (user.Id, user.Username); + } + /// /// Collapse consecutive newlines and cap total line count to prevent newline spam. /// From bdcff74ad51887c6263fe8a73e6953d7b8c0cdb9 Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 14:51:24 +0100 Subject: [PATCH 09/30] 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. --- docs/changelog/v0.2.8.md | 8 + src/EchoHub.Client/Services/ApiClient.cs | 58 +++--- .../Services/ConnectionManager.cs | 122 +++++++------ src/EchoHub.Core/Contracts/IChatService.cs | 5 +- src/EchoHub.Core/Contracts/IUserService.cs | 13 ++ src/EchoHub.Core/DTOs/CommonDtos.cs | 17 ++ src/EchoHub.Server.Irc/IrcCommandHandler.cs | 39 ++-- src/EchoHub.Server.Irc/IrcGatewayService.cs | 3 +- src/EchoHub.Server/Auth/JwtTokenService.cs | 26 +++ .../Controllers/AuthController.cs | 81 +++------ .../Controllers/UsersController.cs | 82 +++------ src/EchoHub.Server/Program.cs | 1 + src/EchoHub.Server/Services/ChatService.cs | 64 ------- src/EchoHub.Server/Services/UserService.cs | 172 ++++++++++++++++++ 14 files changed, 409 insertions(+), 282 deletions(-) create mode 100644 src/EchoHub.Core/Contracts/IUserService.cs create mode 100644 src/EchoHub.Server/Services/UserService.cs diff --git a/docs/changelog/v0.2.8.md b/docs/changelog/v0.2.8.md index 7ffff4b..d147319 100644 --- a/docs/changelog/v0.2.8.md +++ b/docs/changelog/v0.2.8.md @@ -2,6 +2,9 @@ ## 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 @@ -10,6 +13,11 @@ - 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) +## 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) + ## CI - Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index fea3b2a..2d3d351 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -32,7 +32,7 @@ public sealed class ApiClient : IDisposable public async Task 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() @@ -45,7 +45,7 @@ public sealed class ApiClient : IDisposable public async Task 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() @@ -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() @@ -73,7 +73,7 @@ public sealed class ApiClient : IDisposable public async Task 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() @@ -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> GetChannelsAsync() { EnsureAuthenticated(); - var response = await AuthenticatedGetAsync("/api/channels"); + using var response = await AuthenticatedGetAsync("/api/channels"); await EnsureSuccessAsync(response); var paginated = await response.Content.ReadFromJsonAsync>(); return paginated?.Items ?? []; @@ -146,7 +146,7 @@ public sealed class ApiClient : IDisposable public async Task 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() ?? throw new InvalidOperationException("Server returned empty encryption key response."); @@ -156,7 +156,7 @@ public sealed class ApiClient : IDisposable public async Task 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(); } @@ -164,7 +164,7 @@ public sealed class ApiClient : IDisposable public async Task 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(); @@ -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(); @@ -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(); @@ -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(); @@ -214,7 +214,7 @@ public sealed class ApiClient : IDisposable public async Task 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(); @@ -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(); @@ -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 /// /// Performs a GET request with automatic token refresh on 401. + /// Caller is responsible for disposing the returned response. /// private async Task 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 /// /// Performs a request with automatic token refresh on 401. + /// Caller is responsible for disposing the returned response. /// private async Task AuthenticatedRequestAsync(Func> 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 { diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index 50cca1c..6a45d5f 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -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 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 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 ─────────────────────────────────────────────────────────── diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index 97d880a..2c1e0cb 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -25,9 +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 GetUserProfileAsync(string username); + // Query operations (used by IRC gateway for WHOIS) Task> GetChannelsForUserAsync(string username); - Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password); - Task<(Guid UserId, string Username)?> RegisterUserAsync(string username, string password); } diff --git a/src/EchoHub.Core/Contracts/IUserService.cs b/src/EchoHub.Core/Contracts/IUserService.cs new file mode 100644 index 0000000..9043e3d --- /dev/null +++ b/src/EchoHub.Core/Contracts/IUserService.cs @@ -0,0 +1,13 @@ +using EchoHub.Core.DTOs; + +namespace EchoHub.Core.Contracts; + +public interface IUserService +{ + Task RegisterUserAsync(string username, string password, string? displayName = null); + Task AuthenticateUserAsync(string username, string password); + Task GetUserProfileAsync(string username); + Task GetUserByIdAsync(Guid userId); + Task UpdateProfileAsync(Guid userId, string? displayName, string? bio, string? nicknameColor); + Task SetAvatarAsync(Guid userId, string asciiArt); +} diff --git a/src/EchoHub.Core/DTOs/CommonDtos.cs b/src/EchoHub.Core/DTOs/CommonDtos.cs index d41c4f6..5781546 100644 --- a/src/EchoHub.Core/DTOs/CommonDtos.cs +++ b/src/EchoHub.Core/DTOs/CommonDtos.cs @@ -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); +} diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 00b4370..4346181 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -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; @@ -168,23 +171,23 @@ public sealed class IrcCommandHandler _logger.LogDebug("SASL PLAIN auth attempt for user '{Username}' (connection {Id})", username, _conn.ConnectionId); - var result = await _chatService.AuthenticateUserAsync(username, password); + var result = await _userService.AuthenticateUserAsync(username, password); // Auth failed — try registering a new account - if (result is null) - result = await _chatService.RegisterUserAsync(username, password); + if (!result.IsSuccess) + result = await _userService.RegisterUserAsync(username, password); - if (result is null) + if (!result.IsSuccess) { - _logger.LogWarning("SASL auth/register failed for user '{Username}' (connection {Id})", - username, _conn.ConnectionId); + _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})", @@ -288,26 +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); // Auth failed — try registering a new account - if (result is null) - result = await _chatService.RegisterUserAsync(_conn.Nickname!, _conn.Password); + if (!result.IsSuccess) + result = await _userService.RegisterUserAsync(_conn.Nickname!, _conn.Password); - if (result is null) + if (!result.IsSuccess) { await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH, - ":Password incorrect."); + $":{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(); } @@ -559,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) { diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs index d9f555c..357125b 100644 --- a/src/EchoHub.Server.Irc/IrcGatewayService.cs +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -120,10 +120,11 @@ public sealed class IrcGatewayService : BackgroundService try { chatService = _services.GetRequiredService(); + var userService = _services.GetRequiredService(); var channelService = _services.GetRequiredService(); var encryption = _services.GetRequiredService(); var handler = new IrcCommandHandler( - connection, _options, chatService, channelService, encryption, _logger); + connection, _options, chatService, userService, channelService, encryption, _logger); await handler.RunAsync(ct); } diff --git a/src/EchoHub.Server/Auth/JwtTokenService.cs b/src/EchoHub.Server/Auth/JwtTokenService.cs index d0e9477..5906999 100644 --- a/src/EchoHub.Server/Auth/JwtTokenService.cs +++ b/src/EchoHub.Server/Auth/JwtTokenService.cs @@ -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]; diff --git a/src/EchoHub.Server/Controllers/AuthController.cs b/src/EchoHub.Server/Controllers/AuthController.cs index ce61426..9317e8c 100644 --- a/src/EchoHub.Server/Controllers/AuthController.cs +++ b/src/EchoHub.Server/Controllers/AuthController.cs @@ -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 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 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.")), + }; } diff --git a/src/EchoHub.Server/Controllers/UsersController.cs b/src/EchoHub.Server/Controllers/UsersController.cs index 9c0d4c8..8621454 100644 --- a/src/EchoHub.Server/Controllers/UsersController.cs +++ b/src/EchoHub.Server/Controllers/UsersController.cs @@ -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 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.")), + }; } diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 2ab4dcc..f1ac5fb 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -115,6 +115,7 @@ while (true) // ── Chat Service + Broadcasters ───────────────────────────────────── builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 63954c1..0f41271 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -304,73 +304,9 @@ public class ChatService : IChatService } } - public async Task GetUserProfileAsync(string username) - { - username = username.ToLowerInvariant(); - - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - 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> 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(); - - 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); - } - - public async Task<(Guid UserId, string Username)?> RegisterUserAsync(string username, string password) - { - username = username.ToLowerInvariant().Trim(); - - if (!ValidationConstants.UsernameRegex().IsMatch(username)) - return null; - - if (password.Length < 6 || password.Length > ValidationConstants.MaxPasswordLength) - return null; - - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - if (await db.Users.AnyAsync(u => u.Username == username)) - return null; - - var isFirstUser = !await db.Users.AnyAsync(); - - var user = new User - { - Id = Guid.NewGuid(), - Username = username, - PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), - Role = isFirstUser ? ServerRole.Owner : ServerRole.Member, - }; - - db.Users.Add(user); - await db.SaveChangesAsync(); - - return (user.Id, user.Username); - } - /// /// Collapse consecutive newlines and cap total line count to prevent newline spam. /// diff --git a/src/EchoHub.Server/Services/UserService.cs b/src/EchoHub.Server/Services/UserService.cs new file mode 100644 index 0000000..2ab8eab --- /dev/null +++ b/src/EchoHub.Server/Services/UserService.cs @@ -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 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(); + + 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 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(); + + 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 GetUserProfileAsync(string username) + { + var normalizedUsername = username.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); + return user is null ? null : ToProfileDto(user); + } + + public async Task GetUserByIdAsync(Guid userId) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FindAsync(userId); + return user is null ? null : ToProfileDto(user); + } + + public async Task UpdateProfileAsync( + Guid userId, string? displayName, string? bio, string? nicknameColor) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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 SetAvatarAsync(Guid userId, string asciiArt) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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); +} From 6e76065dcb1bfaaeebeb590cba4c8a187786363b Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 14:53:58 +0100 Subject: [PATCH 10/30] fix: correct markdown table formatting and specify code block type in Docker documentation --- docs/articles/docker.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/articles/docker.md b/docs/articles/docker.md index 7eaaa19..dd60837 100644 --- a/docs/articles/docker.md +++ b/docs/articles/docker.md @@ -27,7 +27,7 @@ services: 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) | @@ -50,7 +50,7 @@ All settings are configured through the `.env` file. These are ASP.NET Core envi 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 From 94b31b6056469b7fc4add6ff2eab58e6433a09a0 Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 15:52:18 +0100 Subject: [PATCH 11/30] refactor: replace FakeChatService with FakeUserService for user authentication and profile handling in tests --- .../Irc/IrcCommandHandlerTests.cs | 20 ++++---- src/EchoHub.Tests/Irc/TestHelpers.cs | 48 +++++++++++++++---- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs index 9aed298..35c45f8 100644 --- a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs +++ b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs @@ -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> RunAndCapture(string[] inputLines, Action? 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); diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs index 86001d2..358dec2 100644 --- a/src/EchoHub.Tests/Irc/TestHelpers.cs +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -156,8 +156,6 @@ internal sealed class FakeChatService : IChatService public List 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 ChannelsForUserToReturn { get; set; } = []; public List OnlineUsersToReturn { get; set; } = []; @@ -210,14 +208,8 @@ internal sealed class FakeChatService : IChatService public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) => Task.CompletedTask; - public Task GetUserProfileAsync(string username) => - Task.FromResult(ProfileToReturn); - public Task> GetChannelsForUserAsync(string username) => Task.FromResult(ChannelsForUserToReturn); - - public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) => - Task.FromResult(AuthResult); } /// @@ -258,3 +250,43 @@ internal sealed class FakeChannelService : IChannelService public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) => Task.FromResult(MembershipResult); } + +/// +/// Fake user service that records method calls and returns pre-configured results. +/// +internal sealed class FakeUserService : IUserService +{ + // Configurable results + public UserOperationResult? AuthResult { get; set; } + public UserOperationResult? RegisterResult { get; set; } + public UserProfileDto? ProfileToReturn { get; set; } + + /// + /// Helper to create a success result from a simple userId + username pair. + /// + 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 AuthenticateUserAsync(string username, string password) => + Task.FromResult(AuthResult + ?? UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password.")); + + public Task RegisterUserAsync(string username, string password, string? displayName = null) => + Task.FromResult(RegisterResult + ?? UserOperationResult.Fail(UserError.AlreadyExists, "Username is already taken.")); + + public Task GetUserProfileAsync(string username) => + Task.FromResult(ProfileToReturn); + + public Task GetUserByIdAsync(Guid userId) => + Task.FromResult(ProfileToReturn); + + public Task UpdateProfileAsync(Guid userId, string? displayName, string? bio, string? nicknameColor) => + Task.FromResult(UserOperationResult.Fail(UserError.NotFound, "Not configured")); + + public Task SetAvatarAsync(Guid userId, string asciiArt) => + Task.FromResult(UserOperationResult.Fail(UserError.NotFound, "Not configured")); +} From 0e3dd932af438a841564b7b1844d4cb893ddf436 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:46:42 +0100 Subject: [PATCH 12/30] feat: Add "Check for Updates" menu item --- src/EchoHub.Client/AppOrchestrator.cs | 6 +++++ src/EchoHub.Client/EchoHub.Client.csproj | 2 +- src/EchoHub.Client/Services/UpdateChecker.cs | 24 ++++++++++++++++++++ src/EchoHub.Client/UI/MainWindow.cs | 8 ++++++- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 417a0be..b8c84a7 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -85,6 +85,7 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested; _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested; _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; + _mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -901,6 +902,11 @@ public sealed class AppOrchestrator : IDisposable }, "Failed to download file"); } + private void HandleCheckForUpdatesRequested() + { + RunAsync(_updateService.CheckNowAsync, "Failed to check for updates"); + } + // ── Private Helpers ──────────────────────────────────────────────────── private void FetchAndUpdateOnlineUsers() diff --git a/src/EchoHub.Client/EchoHub.Client.csproj b/src/EchoHub.Client/EchoHub.Client.csproj index e455ebb..fd85d6c 100644 --- a/src/EchoHub.Client/EchoHub.Client.csproj +++ b/src/EchoHub.Client/EchoHub.Client.csproj @@ -5,7 +5,7 @@ - + diff --git a/src/EchoHub.Client/Services/UpdateChecker.cs b/src/EchoHub.Client/Services/UpdateChecker.cs index 5c1b37f..6f64bbb 100644 --- a/src/EchoHub.Client/Services/UpdateChecker.cs +++ b/src/EchoHub.Client/Services/UpdateChecker.cs @@ -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,6 +40,20 @@ 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); @@ -83,6 +100,13 @@ 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) diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 5e0791e..de60da9 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -104,6 +104,11 @@ public sealed class MainWindow : Runnable /// public event Action? OnThemeSelected; + /// + /// Fired when the user requests to check for updates. + /// + public event Action? OnCheckForUpdatesRequested; + /// /// Fired when the user requests to view saved servers. /// @@ -322,7 +327,8 @@ public sealed class MainWindow : Runnable [ new MenuBarItem("_File", [ - new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty) + new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty), + new MenuItem($"_Check for Updates", "Check for new version", () => OnCheckForUpdatesRequested?.Invoke(), Key.Empty) ]), new MenuBarItem("_Server", new View[] { From 241fee67e87edd7f2c8bd24c7bb6cfee02d129f7 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:15:39 +0100 Subject: [PATCH 13/30] chore: update AlwaysUpToDate package to version 2.0.2.20250223 --- src/EchoHub.Client/EchoHub.Client.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EchoHub.Client/EchoHub.Client.csproj b/src/EchoHub.Client/EchoHub.Client.csproj index fd85d6c..70753d0 100644 --- a/src/EchoHub.Client/EchoHub.Client.csproj +++ b/src/EchoHub.Client/EchoHub.Client.csproj @@ -5,7 +5,7 @@ - + From 38df05df416d37a0a127cd09ed242faa4c35f685 Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 18:12:07 +0100 Subject: [PATCH 14/30] feat: implement rollback functionality with pre-update backup and recovery options | Rebase --- .github/workflows/release.yml | 14 +- docs/changelog/v0.2.8.md | 4 + src/EchoHub.Client/AppOrchestrator.cs | 27 ++++ src/EchoHub.Client/Program.cs | 80 ++++++++- .../Services/UpdateBackupService.cs | 153 ++++++++++++++++++ src/EchoHub.Client/Services/UpdateChecker.cs | 71 +++++++- src/EchoHub.Client/UI/MainWindow.cs | 23 ++- 7 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 src/EchoHub.Client/Services/UpdateBackupService.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5e8ab9..834855c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,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 @@ -87,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: | @@ -95,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' @@ -139,9 +149,11 @@ 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 }} diff --git a/docs/changelog/v0.2.8.md b/docs/changelog/v0.2.8.md index d147319..0f7d6ea 100644 --- a/docs/changelog/v0.2.8.md +++ b/docs/changelog/v0.2.8.md @@ -12,6 +12,9 @@ - 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) ## Refactoring @@ -21,3 +24,4 @@ ## 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) diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index b8c84a7..8bd4957 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -86,6 +86,7 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested; _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; _mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested; + _mainWindow.OnRollbackRequested += HandleRollbackRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -907,6 +908,32 @@ public sealed class AppOrchestrator : IDisposable 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() diff --git a/src/EchoHub.Client/Program.cs b/src/EchoHub.Client/Program.cs index 2955339..1394ab2 100644 --- a/src/EchoHub.Client/Program.cs +++ b/src/EchoHub.Client/Program.cs @@ -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,38 @@ Log.Logger = new LoggerConfiguration() Log.Information("EchoHub client starting"); +// == 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(); diff --git a/src/EchoHub.Client/Services/UpdateBackupService.cs b/src/EchoHub.Client/Services/UpdateBackupService.cs new file mode 100644 index 0000000..fb1e5d6 --- /dev/null +++ b/src/EchoHub.Client/Services/UpdateBackupService.cs @@ -0,0 +1,153 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Text.Json; + +using Serilog; + +namespace EchoHub.Client.Services; + +/// +/// Manages pre-update backups and rollback restoration for the auto-updater. +/// Backup location: ~/.echohub/update-backup/ +/// +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"); + + /// + /// True if a backup exists from a recent update (set at startup). + /// + public static bool IsPostUpdate { get; set; } + + /// + /// Creates a ZIP backup of the current app directory before an update. + /// Deletes any previous backup first. Uses fastest compression for speed. + /// + 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); + } + + /// + /// Returns true if a valid backup exists (both ZIP and metadata file present). + /// + public static bool BackupExists() + => File.Exists(BackupZipPath) && File.Exists(BackupInfoPath); + + /// + /// Reads backup metadata. Returns null if no backup exists or metadata is unreadable. + /// + 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; + } + } + + /// + /// Restores the backup ZIP to the app directory, then restarts the process. + /// This method does not return — it calls Environment.Exit(0). + /// + 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); + } + + /// + /// Deletes the backup directory and all contents. + /// + 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; diff --git a/src/EchoHub.Client/Services/UpdateChecker.cs b/src/EchoHub.Client/Services/UpdateChecker.cs index 6f64bbb..e136864 100644 --- a/src/EchoHub.Client/Services/UpdateChecker.cs +++ b/src/EchoHub.Client/Services/UpdateChecker.cs @@ -58,23 +58,52 @@ public sealed class UpdateChecker : IDisposable { 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(); } }); } @@ -111,11 +140,41 @@ public sealed class UpdateChecker : IDisposable 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"); + } }); } diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index de60da9..8f0929c 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1,3 +1,4 @@ +using EchoHub.Client.Services; using EchoHub.Client.Themes; using EchoHub.Client.UI.Chat; using EchoHub.Client.UI.Helpers; @@ -124,6 +125,11 @@ public sealed class MainWindow : Runnable /// public event Action? OnDeleteChannelRequested; + /// + /// Fired when the user requests to rollback to the previous version. + /// + public event Action? OnRollbackRequested; + /// /// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName. /// @@ -323,13 +329,20 @@ public sealed class MainWindow : Runnable }; allUserItems.AddRange(themeItems); + var fileItems = new List(); + 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 MenuItem($"_Check for Updates", "Check for new version", () => OnCheckForUpdatesRequested?.Invoke(), Key.Empty) - ]), + new MenuBarItem("_File", fileItems), new MenuBarItem("_Server", new View[] { new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty), From 3273e62b374afa7ceca76347c61418e3ec102326 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:30:55 +0100 Subject: [PATCH 15/30] feat: improve word wrapping in ChatLine --- src/EchoHub.Client/UI/Chat/ChatLine.cs | 102 +++++++++++------- .../UI/Chat/ChatMessageManager.cs | 3 + src/EchoHub.Client/UI/MainWindow.cs | 2 +- 3 files changed, 70 insertions(+), 37 deletions(-) diff --git a/src/EchoHub.Client/UI/Chat/ChatLine.cs b/src/EchoHub.Client/UI/Chat/ChatLine.cs index cf230cc..087d16e 100644 --- a/src/EchoHub.Client/UI/Chat/ChatLine.cs +++ b/src/EchoHub.Client/UI/Chat/ChatLine.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.RegularExpressions; using EchoHub.Core.Models; using Terminal.Gui.Drawing; @@ -18,6 +19,8 @@ public partial class ChatLine public string? AttachmentUrl { get; set; } public string? AttachmentFileName { get; set; } public MessageType? Type { get; set; } + /// Number of spaces to prepend on continuation lines when this line is word-wrapped. + public int ContinuationIndent { get; set; } public ChatLine(string plainText) { @@ -42,51 +45,78 @@ public partial class ChatLine if (width <= 0 || TextLength <= width) return [this]; - var results = new List(); - var currentSegments = new List(); - 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(); + 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(); + 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 foreach (var wrapped in results) diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index 533a4a1..7e921ed 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -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; diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 8f0929c..45a6a18 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -772,7 +772,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 { From 045515369c3e8c9652e8582701a8a12d090f5fce Mon Sep 17 00:00:00 2001 From: HueByte Date: Tue, 24 Feb 2026 19:44:46 +0100 Subject: [PATCH 16/30] feat: add Chocolatey package and Linux/macOS install script with automated publishing --- .github/workflows/release.yml | 19 ++ docs/articles/getting-started.md | 40 +++- docs/changelog/v0.2.8.md | 6 + packaging/choco/echohub.nuspec | 26 +++ packaging/choco/tools/LICENSE.txt | 21 ++ packaging/choco/tools/VERIFICATION.txt | 17 ++ packaging/choco/tools/chocolateyInstall.ps1 | 18 ++ packaging/choco/tools/chocolateyUninstall.ps1 | 3 + scripts/install.sh | 199 ++++++++++++++++++ 9 files changed, 339 insertions(+), 10 deletions(-) create mode 100644 packaging/choco/echohub.nuspec create mode 100644 packaging/choco/tools/LICENSE.txt create mode 100644 packaging/choco/tools/VERIFICATION.txt create mode 100644 packaging/choco/tools/chocolateyInstall.ps1 create mode 100644 packaging/choco/tools/chocolateyUninstall.ps1 create mode 100644 scripts/install.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 834855c..618917d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -157,3 +157,22 @@ jobs: 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 }} diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md index 7b7bba3..86ebd87 100644 --- a/docs/articles/getting-started.md +++ b/docs/articles/getting-started.md @@ -1,12 +1,33 @@ # 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 +``` -## Docker +### 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: @@ -17,24 +38,23 @@ docker compose up -d See the [Docker guide](docker.md) for configuration, pre-built images, and more. -## Run the Server +### 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 diff --git a/docs/changelog/v0.2.8.md b/docs/changelog/v0.2.8.md index 0f7d6ea..d4c7a8b 100644 --- a/docs/changelog/v0.2.8.md +++ b/docs/changelog/v0.2.8.md @@ -21,7 +21,13 @@ - 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) +## 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 + ## 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) diff --git a/packaging/choco/echohub.nuspec b/packaging/choco/echohub.nuspec new file mode 100644 index 0000000..e4415c7 --- /dev/null +++ b/packaging/choco/echohub.nuspec @@ -0,0 +1,26 @@ + + + + echohub + __VERSION__ + EchoHub + Hue + Hue + false + https://github.com/HueByte/EchoHub/blob/master/LICENSE + https://github.com/HueByte/EchoHub + https://github.com/HueByte/EchoHub + https://huebyte.github.io/EchoHub + https://github.com/HueByte/EchoHub/issues + https://github.com/HueByte/EchoHub/tree/master/packaging/choco + https://raw.githubusercontent.com/HueByte/EchoHub/master/assets/hue_icon.png + 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. + Decentralized terminal chat client with IRC gateway support + chat irc decentralized tui terminal signalr echohub + https://huebyte.github.io/EchoHub/changelog/v__VERSION__.html + Copyright (c) 2026 Hue + + + + + diff --git a/packaging/choco/tools/LICENSE.txt b/packaging/choco/tools/LICENSE.txt new file mode 100644 index 0000000..b28e337 --- /dev/null +++ b/packaging/choco/tools/LICENSE.txt @@ -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. diff --git a/packaging/choco/tools/VERIFICATION.txt b/packaging/choco/tools/VERIFICATION.txt new file mode 100644 index 0000000..62b31d3 --- /dev/null +++ b/packaging/choco/tools/VERIFICATION.txt @@ -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 diff --git a/packaging/choco/tools/chocolateyInstall.ps1 b/packaging/choco/tools/chocolateyInstall.ps1 new file mode 100644 index 0000000..a483c19 --- /dev/null +++ b/packaging/choco/tools/chocolateyInstall.ps1 @@ -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 diff --git a/packaging/choco/tools/chocolateyUninstall.ps1 b/packaging/choco/tools/chocolateyUninstall.ps1 new file mode 100644 index 0000000..0e8b0d9 --- /dev/null +++ b/packaging/choco/tools/chocolateyUninstall.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = 'Stop' + +Uninstall-BinFile -Name 'echohub' diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..82989ad --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,199 @@ +#!/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 +} + +# ── 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 "" + + # Verify + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + echo "Installed successfully! Run 'echohub' to start." + else + echo "Installed to: ${install_dir}/${BINARY_NAME}" + echo "" + echo "WARNING: ${install_dir} is not in your PATH." + echo "Add it to your shell profile:" + echo "" + echo " echo 'export PATH=\"${install_dir}:\$PATH\"' >> ~/.bashrc" + echo " # or for zsh:" + echo " echo 'export PATH=\"${install_dir}:\$PATH\"' >> ~/.zshrc" + fi +} + +main From 6295831045e80d8f4e6b40a791b3e7ea6cd396a6 Mon Sep 17 00:00:00 2001 From: HueByte Date: Tue, 24 Feb 2026 19:44:55 +0100 Subject: [PATCH 17/30] feat: update nuspec metadata and add PATH setup functionality for terminal access --- packaging/choco/echohub.nuspec | 22 ++--- scripts/install.sh | 46 +++++++--- src/EchoHub.Client/Program.cs | 3 + src/EchoHub.Client/Services/PathSetup.cs | 102 +++++++++++++++++++++++ 4 files changed, 151 insertions(+), 22 deletions(-) create mode 100644 src/EchoHub.Client/Services/PathSetup.cs diff --git a/packaging/choco/echohub.nuspec b/packaging/choco/echohub.nuspec index e4415c7..e908659 100644 --- a/packaging/choco/echohub.nuspec +++ b/packaging/choco/echohub.nuspec @@ -4,21 +4,21 @@ echohub __VERSION__ EchoHub - Hue - Hue + HueByte + HueByte false - https://github.com/HueByte/EchoHub/blob/master/LICENSE - https://github.com/HueByte/EchoHub - https://github.com/HueByte/EchoHub - https://huebyte.github.io/EchoHub - https://github.com/HueByte/EchoHub/issues - https://github.com/HueByte/EchoHub/tree/master/packaging/choco - https://raw.githubusercontent.com/HueByte/EchoHub/master/assets/hue_icon.png + https://github.com/HueByteByte/EchoHub/blob/master/LICENSE + https://github.com/HueByteByte/EchoHub + https://github.com/HueByteByte/EchoHub + https://HueBytebyte.github.io/EchoHub + https://github.com/HueByteByte/EchoHub/issues + https://github.com/HueByteByte/EchoHub/tree/master/packaging/choco + https://raw.githubusercontent.com/HueByteByte/EchoHub/master/assets/HueByte_icon.png 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. Decentralized terminal chat client with IRC gateway support chat irc decentralized tui terminal signalr echohub - https://huebyte.github.io/EchoHub/changelog/v__VERSION__.html - Copyright (c) 2026 Hue + https://HueBytebyte.github.io/EchoHub/changelog/v__VERSION__.html + Copyright (c) 2026 HueByte diff --git a/scripts/install.sh b/scripts/install.sh index 82989ad..576c30e 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -130,6 +130,31 @@ download() { 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() { @@ -181,18 +206,17 @@ main() { echo "" - # Verify - if command -v "$BINARY_NAME" >/dev/null 2>&1; then - echo "Installed successfully! Run 'echohub' to start." - else - echo "Installed to: ${install_dir}/${BINARY_NAME}" + # 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 "WARNING: ${install_dir} is not in your PATH." - echo "Add it to your shell profile:" - echo "" - echo " echo 'export PATH=\"${install_dir}:\$PATH\"' >> ~/.bashrc" - echo " # or for zsh:" - echo " echo 'export PATH=\"${install_dir}:\$PATH\"' >> ~/.zshrc" + echo " export PATH=\"${install_dir}:\$PATH\"" fi } diff --git a/src/EchoHub.Client/Program.cs b/src/EchoHub.Client/Program.cs index 1394ab2..f86f1c3 100644 --- a/src/EchoHub.Client/Program.cs +++ b/src/EchoHub.Client/Program.cs @@ -77,6 +77,9 @@ 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()) { diff --git a/src/EchoHub.Client/Services/PathSetup.cs b/src/EchoHub.Client/Services/PathSetup.cs new file mode 100644 index 0000000..c7184d5 --- /dev/null +++ b/src/EchoHub.Client/Services/PathSetup.cs @@ -0,0 +1,102 @@ +using Serilog; + +namespace EchoHub.Client.Services; + +/// +/// Ensures the application's directory is on the system PATH so users +/// can run 'echohub' from any terminal session. +/// +public static class PathSetup +{ + private const string PathMarker = "# Added by EchoHub"; + + /// + /// 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. + /// + 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); + } + } +} From fb4f6c34ed7d44d8c58d8628e3ca493c1f851a91 Mon Sep 17 00:00:00 2001 From: HueByte Date: Tue, 24 Feb 2026 20:50:20 +0100 Subject: [PATCH 18/30] feat: add detailed documentation for authentication, connection, messaging, channels, moderation, and media flows --- docs/flows/authentication.md | 142 +++++++++++++++++++++++ docs/flows/channels.md | 159 ++++++++++++++++++++++++++ docs/flows/connection.md | 150 +++++++++++++++++++++++++ docs/flows/flows.md | 3 + docs/flows/media.md | 118 ++++++++++++++++++++ docs/flows/messaging.md | 210 +++++++++++++++++++++++++++++++++++ docs/flows/moderation.md | 50 +++++++++ docs/flows/toc.yml | 12 ++ docs/toc.yml | 3 + 9 files changed, 847 insertions(+) create mode 100644 docs/flows/authentication.md create mode 100644 docs/flows/channels.md create mode 100644 docs/flows/connection.md create mode 100644 docs/flows/flows.md create mode 100644 docs/flows/media.md create mode 100644 docs/flows/messaging.md create mode 100644 docs/flows/moderation.md create mode 100644 docs/flows/toc.yml diff --git a/docs/flows/authentication.md b/docs/flows/authentication.md new file mode 100644 index 0000000..036aaab --- /dev/null +++ b/docs/flows/authentication.md @@ -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 | diff --git a/docs/flows/channels.md b/docs/flows/channels.md new file mode 100644 index 0000000..278efab --- /dev/null +++ b/docs/flows/channels.md @@ -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 | diff --git a/docs/flows/connection.md b/docs/flows/connection.md new file mode 100644 index 0000000..ff4ccaa --- /dev/null +++ b/docs/flows/connection.md @@ -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 (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 + 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 + IRC->>CH: NICK + IRC->>CH: USER 0 * : + 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 | diff --git a/docs/flows/flows.md b/docs/flows/flows.md new file mode 100644 index 0000000..64f7828 --- /dev/null +++ b/docs/flows/flows.md @@ -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. diff --git a/docs/flows/media.md b/docs/flows/media.md new file mode 100644 index 0000000..f76d27b --- /dev/null +++ b/docs/flows/media.md @@ -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 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 | diff --git a/docs/flows/messaging.md b/docs/flows/messaging.md new file mode 100644 index 0000000..1811e5d --- /dev/null +++ b/docs/flows/messaging.md @@ -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 | diff --git a/docs/flows/moderation.md b/docs/flows/moderation.md new file mode 100644 index 0000000..692fbd1 --- /dev/null +++ b/docs/flows/moderation.md @@ -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 | diff --git a/docs/flows/toc.yml b/docs/flows/toc.yml new file mode 100644 index 0000000..3b56a17 --- /dev/null +++ b/docs/flows/toc.yml @@ -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 diff --git a/docs/toc.yml b/docs/toc.yml index c93ba17..7682921 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -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 From 0c16f44db6292592c7b7ff4674d9e953bc47c350 Mon Sep 17 00:00:00 2001 From: HueByte <ihuebyte@gmail.com> Date: Tue, 24 Feb 2026 20:56:40 +0100 Subject: [PATCH 19/30] 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. --- docs/changelog/v0.2.8.md | 22 +++ src/EchoHub.Client/AppOrchestrator.cs | 149 ++++++++++++++++-- .../Services/ConnectionManager.cs | 4 +- .../Services/EchoHubConnection.cs | 6 +- src/EchoHub.Client/UI/Chat/ChatColors.cs | 39 ++++- src/EchoHub.Client/UI/Chat/ChatLine.cs | 4 +- .../UI/Chat/ChatMessageManager.cs | 9 +- .../UI/ListSources/UserListSource.cs | 11 +- src/EchoHub.Client/UI/MainWindow.cs | 90 +++++++++-- .../Contracts/IChatBroadcaster.cs | 2 +- src/EchoHub.Core/Contracts/IEchoHubClient.cs | 2 +- src/EchoHub.Core/DTOs/ChatDtos.cs | 3 +- src/EchoHub.Server.Irc/IrcBroadcaster.cs | 2 +- src/EchoHub.Server/Program.cs | 1 + src/EchoHub.Server/Services/ChatService.cs | 28 +++- .../Services/LinkEmbedService.cs | 50 +++++- .../Services/MuteExpirationService.cs | 63 ++++++++ .../Services/PresenceTracker.cs | 2 + .../Services/ServerDirectoryService.cs | 3 + .../Services/SignalRBroadcaster.cs | 6 +- src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs | 4 +- 21 files changed, 451 insertions(+), 49 deletions(-) create mode 100644 src/EchoHub.Server/Services/MuteExpirationService.cs diff --git a/docs/changelog/v0.2.8.md b/docs/changelog/v0.2.8.md index d4c7a8b..8cf62c3 100644 --- a/docs/changelog/v0.2.8.md +++ b/docs/changelog/v0.2.8.md @@ -7,6 +7,14 @@ - 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 ## New Features @@ -15,17 +23,31 @@ - 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 ## 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 diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 8bd4957..5c1f52d 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -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(); @@ -87,6 +89,8 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; _mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested; _mainWindow.OnRollbackRequested += HandleRollbackRequested; + _mainWindow.OnUserProfileRequested += HandleViewProfile; + _mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -381,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(); }; @@ -407,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)) @@ -426,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); } }); }; @@ -469,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"); @@ -535,6 +631,7 @@ public sealed class AppOrchestrator : IDisposable private void HandleDisconnect() { Log.Information("Disconnecting from server"); + lock (_channelUsersLock) _channelUsers.Clear(); RunAsync(async () => { @@ -623,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); @@ -826,6 +936,8 @@ public sealed class AppOrchestrator : IDisposable if (history.Count > 0) _messageManager.LoadHistory(channel.Name, history); }); + + FetchAndUpdateOnlineUsers(); }, "Failed to create channel"); } @@ -881,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; @@ -890,14 +1012,22 @@ 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"); @@ -946,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) diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index 6a45d5f..ebb4285 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -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; @@ -234,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); diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index 6e6a17b..d59ec6b 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -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) => diff --git a/src/EchoHub.Client/UI/Chat/ChatColors.cs b/src/EchoHub.Client/UI/Chat/ChatColors.cs index aeeb2ab..f6fc1ec 100644 --- a/src/EchoHub.Client/UI/Chat/ChatColors.cs +++ b/src/EchoHub.Client/UI/Chat/ChatColors.cs @@ -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,40 @@ public static partial class ChatColors lastIndex = match.Index + match.Length; } - 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(); } diff --git a/src/EchoHub.Client/UI/Chat/ChatLine.cs b/src/EchoHub.Client/UI/Chat/ChatLine.cs index 087d16e..9f67afc 100644 --- a/src/EchoHub.Client/UI/Chat/ChatLine.cs +++ b/src/EchoHub.Client/UI/Chat/ChatLine.cs @@ -19,6 +19,7 @@ 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; } @@ -118,13 +119,14 @@ public partial class ChatLine 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; diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index 7e921ed..440b57e 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -272,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) { @@ -329,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)) { diff --git a/src/EchoHub.Client/UI/ListSources/UserListSource.cs b/src/EchoHub.Client/UI/ListSources/UserListSource.cs index 50075b2..55010e9 100644 --- a/src/EchoHub.Client/UI/ListSources/UserListSource.cs +++ b/src/EchoHub.Client/UI/ListSources/UserListSource.cs @@ -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) diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 45a6a18..1904d6d 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using EchoHub.Client.Services; using EchoHub.Client.Themes; using EchoHub.Client.UI.Chat; @@ -19,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; @@ -140,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; @@ -250,6 +261,7 @@ public sealed class MainWindow : Runnable }; _usersListSource = new UserListSource(); _usersList.Source = _usersListSource; + _usersList.Accepting += OnUsersListAccepting; _usersFrame.Add(_usersList); Add(_usersFrame); @@ -410,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; } } @@ -862,14 +923,14 @@ public sealed class MainWindow : Runnable var name = u.DisplayName ?? u.Username; var roleTag = u.Role switch { - ServerRole.Owner => "\u2605", // ★ - ServerRole.Admin => "\u2666", // ♦ - ServerRole.Mod => "\u2740", // ❀ + ServerRole.Owner => "\u2605 ", // ★ + ServerRole.Admin => "\u2666 ", // ♦ + ServerRole.Mod => "\u2740 ", // ❀ _ => "" }; var text = $"{statusIcon} {roleTag}{name}"; var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor); - return (text, nameColor); + return (text, nameColor, u.Username); }).ToList(); _usersListSource.Update(displayItems); @@ -877,4 +938,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(); } diff --git a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs index c88bc81..4e7585d 100644 --- a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs +++ b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs @@ -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); diff --git a/src/EchoHub.Core/Contracts/IEchoHubClient.cs b/src/EchoHub.Core/Contracts/IEchoHubClient.cs index 242a66e..87e6cf0 100644 --- a/src/EchoHub.Core/Contracts/IEchoHubClient.cs +++ b/src/EchoHub.Core/Contracts/IEchoHubClient.cs @@ -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); diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index ce742d9..32cd71f 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -44,4 +44,5 @@ public record EmbedDto( string? Title, string? Description, string? ImageAscii, - string Url); + string Url, + string? ThemeColor = null); diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs index 058cd79..07b41e4 100644 --- a/src/EchoHub.Server.Irc/IrcBroadcaster.cs +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -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)) { diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index f1ac5fb..b6fde78 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -109,6 +109,7 @@ while (true) builder.Services.AddSingleton<LinkEmbedService>(); builder.Services.AddHostedService<ServerDirectoryService>(); builder.Services.AddHostedService<FileCleanupService>(); + builder.Services.AddHostedService<MuteExpirationService>(); // ── Encryption ───────────────────────────────────────────────────── builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>(); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 0f41271..cf7bb17 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -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, diff --git a/src/EchoHub.Server/Services/LinkEmbedService.cs b/src/EchoHub.Server/Services/LinkEmbedService.cs index e424730..ca87838 100644 --- a/src/EchoHub.Server/Services/LinkEmbedService.cs +++ b/src/EchoHub.Server/Services/LinkEmbedService.cs @@ -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[^>]*>([^<]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex TitleTagRegex(); + + // + [GeneratedRegex(@"]*?name\s*=\s*([""'])theme-color\1[^>]*?content\s*=\s*([""'])(.*?)\2[^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)] + private static partial Regex ThemeColorRegex(); + + // + [GeneratedRegex(@"]*?content\s*=\s*([""'])(.*?)\1[^>]*?name\s*=\s*([""'])theme-color\3[^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)] + private static partial Regex ThemeColorReversedRegex(); } diff --git a/src/EchoHub.Server/Services/MuteExpirationService.cs b/src/EchoHub.Server/Services/MuteExpirationService.cs new file mode 100644 index 0000000..cfbdb99 --- /dev/null +++ b/src/EchoHub.Server/Services/MuteExpirationService.cs @@ -0,0 +1,63 @@ +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Server.Services; + +/// +/// Background service that periodically unmutes users whose timed mute has expired. +/// +public sealed class MuteExpirationService : BackgroundService +{ + private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(15); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public MuteExpirationService(IServiceScopeFactory scopeFactory, ILogger 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(); + + 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); + } +} diff --git a/src/EchoHub.Server/Services/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs index 0ae3d42..f97278d 100644 --- a/src/EchoHub.Server/Services/PresenceTracker.cs +++ b/src/EchoHub.Server/Services/PresenceTracker.cs @@ -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)) diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs index 633f5d7..521f619 100644 --- a/src/EchoHub.Server/Services/ServerDirectoryService.cs +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -170,6 +170,9 @@ public sealed class ServerDirectoryService : BackgroundService var currentCount = _presenceTracker.GetOnlineUserCount(); + if (currentCount == _lastReportedUserCount) + continue; + try { await connection.InvokeAsync("UpdateUserCount", currentCount, ct); diff --git a/src/EchoHub.Server/Services/SignalRBroadcaster.cs b/src/EchoHub.Server/Services/SignalRBroadcaster.cs index 546dde6..4c8b660 100644 --- a/src/EchoHub.Server/Services/SignalRBroadcaster.cs +++ b/src/EchoHub.Server/Services/SignalRBroadcaster.cs @@ -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) diff --git a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs index eca1170..65c372a 100644 --- a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs +++ b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs @@ -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()); From a0d9d86956c2b53a51de32da196f09ddf34a4ca3 Mon Sep 17 00:00:00 2001 From: HueByte Date: Tue, 24 Feb 2026 21:00:51 +0100 Subject: [PATCH 20/30] fix: update markdownlint configuration to allow compact table pipe style --- .markdownlint-cli2.jsonc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 1b4e73d..d091c60 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -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"], From 62996f85e98e473fa7eb975e503b90cf32f946eb Mon Sep 17 00:00:00 2001 From: HueByte Date: Tue, 24 Feb 2026 21:15:41 +0100 Subject: [PATCH 21/30] fix: add space between role tag and username in status display --- src/EchoHub.Client/UI/MainWindow.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 1904d6d..9905312 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -928,7 +928,7 @@ public sealed partial class MainWindow : Runnable ServerRole.Mod => "\u2740 ", // ❀ _ => "" }; - var text = $"{statusIcon} {roleTag}{name}"; + var text = $"{statusIcon} {roleTag} {name}"; var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor); return (text, nameColor, u.Username); }).ToList(); From 5391d2cb1cfe8eefd04b1749d44a912f2bdc5c12 Mon Sep 17 00:00:00 2001 From: HueByte Date: Tue, 24 Feb 2026 21:44:43 +0100 Subject: [PATCH 22/30] fix: improve role tag formatting and handle empty role cases in user status display --- src/EchoHub.Client/UI/Chat/ChatColors.cs | 4 ++++ src/EchoHub.Client/UI/MainWindow.cs | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/EchoHub.Client/UI/Chat/ChatColors.cs b/src/EchoHub.Client/UI/Chat/ChatColors.cs index f6fc1ec..0eee5a6 100644 --- a/src/EchoHub.Client/UI/Chat/ChatColors.cs +++ b/src/EchoHub.Client/UI/Chat/ChatColors.cs @@ -39,6 +39,10 @@ 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 = []; diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 9905312..f46e5cc 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -923,12 +923,14 @@ public sealed partial class MainWindow : Runnable var name = u.DisplayName ?? u.Username; var roleTag = u.Role switch { - ServerRole.Owner => "\u2605 ", // ★ - ServerRole.Admin => "\u2666 ", // ♦ - ServerRole.Mod => "\u2740 ", // ❀ + ServerRole.Owner => "\u2605", // ★ + ServerRole.Admin => "\u2666", // ♦ + 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, u.Username); }).ToList(); From 24a5bdb9a1cfb51d7b42a72866c3fbbac8e2e75a Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:10:15 +0100 Subject: [PATCH 23/30] fix: ghost channel when trying to join a channel that doesn't exist --- src/EchoHub.Client/Services/EchoHubConnection.cs | 6 ++++-- src/EchoHub.Core/DTOs/ChatDtos.cs | 2 ++ src/EchoHub.Server/Hubs/ChatHub.cs | 12 ++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index d59ec6b..c79fccd 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -136,8 +136,10 @@ public sealed class EchoHubConnection : IAsyncDisposable public async Task> JoinChannelAsync(string channelName) { - var messages = await _connection.InvokeAsync>("JoinChannel", channelName); - return DecryptMessages(messages); + var result = await _connection.InvokeAsync("JoinChannel", channelName); + if (!result.Success) + throw new InvalidOperationException(result.Error ?? "Failed to join channel."); + return DecryptMessages(result.History); } public async Task LeaveChannelAsync(string channelName) diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 32cd71f..6a73e4d 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -15,6 +15,8 @@ public record MessageDto( long? AttachmentFileSize = null, List? Embeds = null); +public record JoinChannelResult(bool Success, List History, string? Error = null); + public record ChannelDto( Guid Id, string Name, diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index ee8b516..eb73a7a 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -56,7 +56,7 @@ public class ChatHub : Hub } } - public async Task> JoinChannel(string channelName) + public async Task JoinChannel(string channelName) { try { @@ -64,19 +64,15 @@ public class ChatHub : Hub 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}"); } } From b0adb55b8f032c51979ab7ba231f8cb610f9acaf Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:14:49 +0100 Subject: [PATCH 24/30] refactor: Move JoinChannelResult to the correct position in ChatDtos.cs --- src/EchoHub.Core/DTOs/ChatDtos.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 6a73e4d..3a7e624 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -15,8 +15,6 @@ public record MessageDto( long? AttachmentFileSize = null, List? Embeds = null); -public record JoinChannelResult(bool Success, List History, string? Error = null); - public record ChannelDto( Guid Id, string Name, @@ -41,6 +39,8 @@ public record UpdateTopicRequest(string? Topic); public record SendUrlRequest(string Url); +public record JoinChannelResult(bool Success, List History, string? Error = null); + public record EmbedDto( string? SiteName, string? Title, From 29bfd87d8b41dbdd43d45e8e4d0a338489a5074d Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:10:15 +0100 Subject: [PATCH 25/30] fix: ghost channel when trying to join a channel that doesn't exist --- src/EchoHub.Core/DTOs/ChatDtos.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 3a7e624..24bf514 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -15,6 +15,8 @@ public record MessageDto( long? AttachmentFileSize = null, List? Embeds = null); +public record JoinChannelResult(bool Success, List History, string? Error = null); + public record ChannelDto( Guid Id, string Name, From 3896640ac76bb110396ab8b757f006eef9e5a8e6 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:14:49 +0100 Subject: [PATCH 26/30] refactor: Move JoinChannelResult to the correct position in ChatDtos.cs --- src/EchoHub.Core/DTOs/ChatDtos.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 24bf514..3a7e624 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -15,8 +15,6 @@ public record MessageDto( long? AttachmentFileSize = null, List? Embeds = null); -public record JoinChannelResult(bool Success, List History, string? Error = null); - public record ChannelDto( Guid Id, string Name, From d4f540285b7936470382856fdb1808983eec134c Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:45:18 +0100 Subject: [PATCH 27/30] chore: add ghost channel fixes to changelog --- docs/changelog/v0.2.8.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog/v0.2.8.md b/docs/changelog/v0.2.8.md index 8cf62c3..7882ec8 100644 --- a/docs/changelog/v0.2.8.md +++ b/docs/changelog/v0.2.8.md @@ -15,6 +15,7 @@ - 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 @@ -38,6 +39,7 @@ - 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` to allow for better error handling ## Distribution From 0330f24e45eb50187133d03791dfb451143691fd Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:52:43 +0100 Subject: [PATCH 28/30] Add todo list --- docs/todo.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/todo.md diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..ee37880 --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,22 @@ +- [ ] fix the chat trailing; when user scrolls up, and somebody sends a message – the chat instantly "teleports" to the very bottom +- [ ] disable the autorun of files (maybe keep for mp4? gotta do some sec research on it) +- [ ] 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 +- [ ] 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) +- [ ] 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 +- [ ] 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) +- [ ] Another thing would be stateful userlist – basically fetch once and listen for userlist updates +- [ ] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update +- [ ] space between mod|admin "icon" and username From 9baa3ff5665adf47dcaed353ca6f08516516f287 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:50:44 +0100 Subject: [PATCH 29/30] chore: fix lint errors in todo.md --- docs/todo.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index ee37880..0d2fd5d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,10 +1,12 @@ +# TODO + - [ ] fix the chat trailing; when user scrolls up, and somebody sends a message – the chat instantly "teleports" to the very bottom - [ ] disable the autorun of files (maybe keep for mp4? gotta do some sec research on it) - [ ] 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 + - ref: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-10.0 - [ ] 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) - [ ] make usernames in messages, mentions and in the user list clickable to open the user profile @@ -12,7 +14,7 @@ - [ ] when trying to /join a channel that doesn't exist a client side-ghost channel gets created that does not work - [ ] 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 + - 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 From 6af85559cb2e9713d825fdc512a28921dd870afe Mon Sep 17 00:00:00 2001 From: HueByte Date: Tue, 24 Feb 2026 21:59:42 +0100 Subject: [PATCH 30/30] chore: update todo list with completed tasks --- docs/todo.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 0d2fd5d..07726db 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,24 +1,24 @@ # TODO - [ ] fix the chat trailing; when user scrolls up, and somebody sends a message – the chat instantly "teleports" to the very bottom -- [ ] disable the autorun of files (maybe keep for mp4? gotta do some sec research on it) -- [ ] when user creates a new channel, he gets moved to that channel; but the userlist does not refresh the state on that – it refreshes when user re-enters the channel again +- [x] 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 -- [ ] The vertical line of embeds should be the same colour as theme-color meta tag of the source +- [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) -- [ ] make usernames in messages, mentions and in the user list clickable to open the user profile +- [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 -- [ ] unmute timer does not seem to 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) -- [ ] Another thing would be stateful userlist – basically fetch once and listen for userlist updates -- [ ] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update -- [ ] space between mod|admin "icon" and username +- [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